mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-07 15:31:30 +02:00
[client] UI refactor (#6069)
Refactor UI --------- 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: Claude <noreply@anthropic.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
7
client/ui/frontend/.prettierignore
Normal file
7
client/ui/frontend/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
dist
|
||||
build
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
wailsjs
|
||||
*.min.js
|
||||
*.min.css
|
||||
12
client/ui/frontend/.prettierrc
Normal file
12
client/ui/frontend/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindFunctions": ["cn", "clsx", "cva", "tw"]
|
||||
}
|
||||
296
client/ui/frontend/WAILS-API.md
Normal file
296
client/ui/frontend/WAILS-API.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Wails Go API reference (frontend)
|
||||
|
||||
Reference for every binding method and model shape exposed to the frontend. Generated from `client/ui/services/*.go` via `wails3 generate bindings -clean=true -ts` — regenerate after any Go-side change. Authoritative source is always `bindings/github.com/netbirdio/netbird/client/ui/services/*.ts`.
|
||||
|
||||
Every method returns `$CancellablePromise<T>` (a Wails3 wrapper around `Promise`). Call `.cancel()` to abort the underlying gRPC call; in practice we just `await` and let it run.
|
||||
|
||||
## Imports
|
||||
|
||||
```ts
|
||||
// Services
|
||||
import {
|
||||
Connection, Peers, ProfileSwitcher, Profiles,
|
||||
Settings, Networks, Forwarding, Debug, Update, WindowManager,
|
||||
I18n, Preferences,
|
||||
} from "@bindings/services";
|
||||
|
||||
// Models (types-only)
|
||||
import type {
|
||||
Status, PeerStatus, PeerLink, LocalPeer, SystemEvent,
|
||||
Profile, ProfileRef, ActiveProfile,
|
||||
Config, ConfigParams, SetConfigParams, Features,
|
||||
Network, SelectNetworksParams,
|
||||
ForwardingRule, PortInfo, PortRange,
|
||||
LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams,
|
||||
DebugBundleParams, DebugBundleResult, LogLevel,
|
||||
UpdateResult, UpdateAvailable, UpdateProgress,
|
||||
} from "@bindings/services/models.js";
|
||||
|
||||
// i18n / preferences models live in sibling packages, not services/models
|
||||
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
|
||||
import type { UIPreferences } from "@bindings/preferences/models.js";
|
||||
```
|
||||
|
||||
## Push events
|
||||
|
||||
Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers receive `{ data: <payload> }`.
|
||||
|
||||
| Event | Payload | Fires on |
|
||||
|---|---|---|
|
||||
| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. |
|
||||
| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. |
|
||||
| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). |
|
||||
| `netbird:preferences:changed` | `{ language: string }` | Fires after every successful `Preferences.SetLanguage` (including the caller's own window). `src/lib/i18n.ts` subscribes and calls `i18next.changeLanguage`. |
|
||||
| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). |
|
||||
| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). |
|
||||
| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow. `layouts/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()`; no Go-side emitter today. |
|
||||
|
||||
The two stream loops behind `netbird:status` and `netbird:event` start automatically — `main.go` calls `peers.Watch(context.Background())` at boot. `Peers.Watch` is still exported but the frontend doesn't need to invoke it.
|
||||
|
||||
## `Connection`
|
||||
|
||||
```ts
|
||||
Connection.Login(p: LoginParams): Promise<LoginResult>
|
||||
Connection.WaitSSOLogin(p: WaitSSOParams): Promise<string> // returns email
|
||||
Connection.Up(p: UpParams): Promise<void> // async on the daemon
|
||||
Connection.Down(): Promise<void>
|
||||
Connection.Logout(p: LogoutParams): Promise<void>
|
||||
Connection.OpenURL(url: string): Promise<void> // honors $BROWSER
|
||||
```
|
||||
|
||||
`Login` Down-resets the daemon first to dislodge a stale `WaitSSOLogin` (so a previously abandoned SSO flow doesn't fail the next attempt). `Up` always uses async mode — status flows back through `netbird:status`. **Do not call `Up` on an `Idle` / `NeedsLogin` daemon** — the daemon's internal 50s `waitForUp` will block and return `DeadlineExceeded`.
|
||||
|
||||
Full SSO sequence: `Login` → if `result.needsSsoLogin`, open `result.verificationUriComplete` via `OpenURL` + `WindowManager.OpenBrowserLogin(uri)` → `WaitSSOLogin({ userCode })` → `Up({})`. The canonical implementation is `startLogin()` in `layouts/ConnectionStatusSwitch.tsx`.
|
||||
|
||||
## `Peers`
|
||||
|
||||
```ts
|
||||
Peers.Get(): Promise<Status> // one-shot snapshot
|
||||
Peers.Watch(): Promise<void> // already invoked from main.go
|
||||
Peers.BeginProfileSwitch(): Promise<void>
|
||||
Peers.CancelProfileSwitch(): Promise<void>
|
||||
```
|
||||
|
||||
`BeginProfileSwitch` and `CancelProfileSwitch` are normally driven by `ProfileSwitcher` / the tray, not the frontend.
|
||||
|
||||
## `ProfileSwitcher`
|
||||
|
||||
```ts
|
||||
ProfileSwitcher.SwitchActive(p: ProfileRef): Promise<void>
|
||||
```
|
||||
|
||||
The single entry point both tray and frontend should use for profile flips. Applies the reconnect policy below, mirrors the switch into the user-side `profilemanager` (so the CLI's `netbird up` reads a consistent active profile), and drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`.
|
||||
|
||||
Reconnect policy (driven by `prevStatus` captured at entry):
|
||||
|
||||
| Previous status | Action | Optimistic UI | Suppressed events until new flow |
|
||||
|---|---|---|---|
|
||||
| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle |
|
||||
| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle |
|
||||
| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — |
|
||||
| Idle | Switch only | (no change) | — |
|
||||
|
||||
## `Profiles`
|
||||
|
||||
```ts
|
||||
Profiles.Username(): Promise<string> // current OS username
|
||||
Profiles.List(username: string): Promise<Profile[]>
|
||||
Profiles.GetActive(): Promise<ActiveProfile>
|
||||
Profiles.Switch(p: ProfileRef): Promise<void> // raw daemon RPC; prefer ProfileSwitcher.SwitchActive
|
||||
Profiles.Add(p: ProfileRef): Promise<void>
|
||||
Profiles.Remove(p: ProfileRef): Promise<void>
|
||||
```
|
||||
|
||||
`Profile.email` is populated by the **UI process** reading the per-profile state file (`~/Library/Application Support/netbird/<name>.state.json` on macOS), not by the daemon — the daemon runs as root and can't read user-owned files.
|
||||
|
||||
## `Settings`
|
||||
|
||||
```ts
|
||||
Settings.GetConfig(p: ConfigParams): Promise<Config>
|
||||
Settings.SetConfig(p: SetConfigParams): Promise<void> // partial update
|
||||
Settings.GetFeatures(): Promise<Features> // operator-disabled UI sections
|
||||
```
|
||||
|
||||
`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons.
|
||||
|
||||
**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`.
|
||||
|
||||
`SetConfigParams` carries one field that `Config` does not: `disableFirewall`. There's no current GET path for it.
|
||||
|
||||
## `Networks`
|
||||
|
||||
```ts
|
||||
Networks.List(): Promise<Network[]>
|
||||
Networks.Select(p: SelectNetworksParams): Promise<void>
|
||||
Networks.Deselect(p: SelectNetworksParams): Promise<void>
|
||||
```
|
||||
|
||||
`SelectNetworksParams.append=true` merges into the existing selection; `false` replaces. `all=true` ignores `networkIds` and targets every network (Select-All / Deselect-All).
|
||||
|
||||
Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side.
|
||||
|
||||
## `Forwarding`
|
||||
|
||||
```ts
|
||||
Forwarding.List(): Promise<ForwardingRule[]>
|
||||
```
|
||||
|
||||
`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`).
|
||||
|
||||
## `Debug`
|
||||
|
||||
```ts
|
||||
Debug.GetLogLevel(): Promise<LogLevel>
|
||||
Debug.SetLogLevel(lvl: LogLevel): Promise<void>
|
||||
Debug.Bundle(p: DebugBundleParams): Promise<DebugBundleResult>
|
||||
Debug.RevealFile(path: string): Promise<void> // OS file-manager focus
|
||||
```
|
||||
|
||||
**Log level case sensitivity bug:** `proto.LogLevel_value` is keyed on uppercase enum names (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`, `"UNKNOWN"`). `Debug.SetLogLevel` calls `proto.LogLevel_value[lvl.Level]` and falls back to `INFO` on miss. `useDebugBundle` currently passes `"trace"` (lowercase), which silently maps to `INFO` — the trace-capture flow doesn't actually raise the log level today. To raise to trace, pass `{ level: "TRACE" }`. Fix on the cleanup list.
|
||||
|
||||
`Debug.Bundle` uploads when `uploadUrl != ""`. Result fields: `path` (local copy), `uploadedKey` (set on success), `uploadFailureReason` (set on upload failure — the local copy is still saved).
|
||||
|
||||
## `Update`
|
||||
|
||||
```ts
|
||||
Update.Trigger(): Promise<UpdateResult> // start the install
|
||||
Update.GetInstallerResult(): Promise<UpdateResult> // poll the outcome (long-running)
|
||||
Update.Quit(): Promise<void> // 100ms later, app.Quit()
|
||||
```
|
||||
|
||||
Typical enforced-update flow on the `/update` route: call `Trigger` once, then poll `GetInstallerResult` every 2s with a 15-minute total timeout. On `success: true` call `Quit`. On `success: false` show `errorMsg`. If the gRPC poll itself starts failing for `DAEMON_DOWN_GRACE_MS` (5s), treat that as success and quit too — the installer commonly takes the daemon offline mid-upgrade. See `pages/Update.tsx` for the canonical implementation.
|
||||
|
||||
## `WindowManager`
|
||||
|
||||
```ts
|
||||
WindowManager.OpenSettings(): Promise<void>
|
||||
WindowManager.OpenBrowserLogin(uri: string): Promise<void> // uri appended as ?uri=…
|
||||
WindowManager.CloseBrowserLogin(): Promise<void>
|
||||
WindowManager.OpenError(title: string, message: string): Promise<void> // custom branded error window; both query-escaped as ?title=…&message=…
|
||||
WindowManager.CloseError(): Promise<void>
|
||||
```
|
||||
|
||||
Prefer `errorDialog({Title, Message})` from `lib/dialogs.ts` over calling `OpenError` directly — it's the app's single error surface (the old native MessageBox wrapper now routes here). Both strings must be pre-localised.
|
||||
|
||||
Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`.
|
||||
|
||||
## `I18n`
|
||||
|
||||
```ts
|
||||
I18n.Languages(): Promise<Language[]> // from _index.json
|
||||
I18n.Bundle(code: LanguageCode): Promise<Record<string,string>> // full key→text map
|
||||
```
|
||||
|
||||
Source of truth is `client/ui/i18n/locales/` (shared with the Go tray). The frontend's i18next bootstrap doesn't need `I18n.Bundle` at runtime (bundles are statically imported by Vite via the glob in `src/lib/i18n.ts`), but the language picker reads `I18n.Languages()` so the list matches `_index.json` without duplicating it in TS.
|
||||
|
||||
## `Preferences`
|
||||
|
||||
```ts
|
||||
Preferences.Get(): Promise<UIPreferences> // { language: string }
|
||||
Preferences.SetLanguage(code: LanguageCode): Promise<void> // rejects on unknown code
|
||||
```
|
||||
|
||||
`SetLanguage` validates against the loaded `i18n.Bundle`, persists to `os.UserConfigDir()/netbird/ui-preferences.json`, and emits `netbird:preferences:changed`. The frontend's `src/lib/i18n.ts` listens to that event and calls `i18next.changeLanguage` so a flip in any window paints in all of them. Missing preferences file → defaults to `en`, written on first read.
|
||||
|
||||
## Daemon `Status.status` values
|
||||
|
||||
Mirror `internal.Status*` in `client/internal/state.go` plus the synthetic UI label:
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `"Idle"` | Tunnel down (Up never invoked or Down completed) |
|
||||
| `"Connecting"` | Up in progress |
|
||||
| `"Connected"` | Tunnel up |
|
||||
| `"NeedsLogin"` | Fresh install or token cleared; needs Login → SSO → Up |
|
||||
| `"LoginFailed"` | Previous Login attempt errored |
|
||||
| `"SessionExpired"` | SSO token expired; needs re-Login |
|
||||
| `"DaemonUnavailable"` | **Synthetic** — UI side, emitted when the daemon gRPC socket is unreachable. Not a real daemon enum. |
|
||||
|
||||
The tray also reads a tray-only synthetic `"Error"` for icon purposes; the frontend doesn't see that.
|
||||
|
||||
## Model field reference
|
||||
|
||||
`Status`:
|
||||
```ts
|
||||
{ status, daemonVersion: string;
|
||||
management: PeerLink; signal: PeerLink;
|
||||
local: LocalPeer;
|
||||
peers: PeerStatus[];
|
||||
events: SystemEvent[]; }
|
||||
```
|
||||
|
||||
`PeerLink`: `{ url: string; connected: boolean; error?: string }`.
|
||||
|
||||
`LocalPeer`: `{ ip, pubKey, fqdn: string; networks: string[] }`.
|
||||
|
||||
`PeerStatus`:
|
||||
```ts
|
||||
{ ip, pubKey, fqdn, connStatus: string;
|
||||
connStatusUpdateUnix: number;
|
||||
relayed: boolean;
|
||||
localIceCandidateType, remoteIceCandidateType: string; // pion: "host"|"srflx"|"prflx"|"relay"|""
|
||||
localIceCandidateEndpoint, remoteIceCandidateEndpoint: string;
|
||||
bytesRx, bytesTx, latencyMs, lastHandshakeUnix: number;
|
||||
relayAddress: string; // set when relayed=true
|
||||
rosenpassEnabled: boolean;
|
||||
networks: string[]; }
|
||||
```
|
||||
|
||||
`SystemEvent`:
|
||||
```ts
|
||||
{ id: string;
|
||||
severity: string; // "info"|"warning"|"error"|"critical" (lowercased proto enum, "SystemEvent_" prefix stripped)
|
||||
category: string; // "network"|"dns"|"authentication"|"connectivity"|"system" (same casing rules)
|
||||
message: string; // technical / log line
|
||||
userMessage: string; // human-friendly — render this
|
||||
timestamp: number; // unix seconds
|
||||
metadata: Record<string, string>; } // keys: "new_version_available", "enforced", "id", "network", "version", "progress_window", …
|
||||
```
|
||||
|
||||
`Profile`: `{ name: string; isActive: boolean; email: string }`.
|
||||
|
||||
`Config` (read-only mirror, all required):
|
||||
```ts
|
||||
{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string;
|
||||
wireguardPort, mtu, sshJwtCacheTtl: number;
|
||||
disableAutoConnect, serverSshAllowed,
|
||||
rosenpassEnabled, rosenpassPermissive,
|
||||
disableNotifications, lazyConnectionEnabled, blockInbound,
|
||||
networkMonitor, disableClientRoutes, disableServerRoutes,
|
||||
disableDns, disableIpv6, blockLanAccess,
|
||||
enableSshRoot, enableSshSftp,
|
||||
enableSshLocalPortForwarding, enableSshRemotePortForwarding,
|
||||
disableSshAuth: boolean; }
|
||||
```
|
||||
|
||||
`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings.
|
||||
|
||||
`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`.
|
||||
|
||||
`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record<string, string[]> }`.
|
||||
|
||||
`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`.
|
||||
|
||||
`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated).
|
||||
|
||||
`PortRange`: `{ start, end: number }` (inclusive).
|
||||
|
||||
`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`.
|
||||
|
||||
`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`.
|
||||
|
||||
`WaitSSOParams`: `{ userCode, hostname: string }`. Resolves to the user's email.
|
||||
|
||||
`UpParams` / `LogoutParams` / `ProfileRef` / `ConfigParams` / `ActiveProfile`: all `{ profileName, username: string }` (different names but same shape — kept distinct by Wails for clarity).
|
||||
|
||||
`DebugBundleParams`: `{ anonymize, systemInfo: boolean; uploadUrl: string; logFileCount: number }`.
|
||||
|
||||
`DebugBundleResult`: `{ path, uploadedKey, uploadFailureReason: string }`.
|
||||
|
||||
`LogLevel`: `{ level: string }` — **uppercase** proto enum name (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`).
|
||||
|
||||
`UpdateResult`: `{ success: boolean; errorMsg: string }`.
|
||||
|
||||
`UpdateAvailable`: `{ version: string; enforced: boolean }`.
|
||||
|
||||
`UpdateProgress`: `{ action: string; version: string }`.
|
||||
75
client/ui/frontend/eslint.config.js
Normal file
75
client/ui/frontend/eslint.config.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import react from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||
import globals from "globals";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "bindings/**", "sonar/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
react,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
"jsx-a11y": jsxA11y,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
globals: { ...globals.browser },
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: { version: "detect" },
|
||||
},
|
||||
rules: {
|
||||
// ----- a11y / semantic HTML (jsx-a11y recommended) -----
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
"jsx-a11y/no-autofocus": ["warn", { ignoreNonDOM: true }],
|
||||
|
||||
// ----- React -----
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs["jsx-runtime"].rules,
|
||||
"react/prop-types": "off",
|
||||
"react/jsx-no-target-blank": ["error", { allowReferrer: true }],
|
||||
"react/self-closing-comp": "warn",
|
||||
|
||||
// ----- React hooks -----
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
|
||||
// ----- Vite / HMR (Fast Refresh) -----
|
||||
"react-refresh/only-export-components": "off",
|
||||
|
||||
// ----- TypeScript -----
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
|
||||
// ----- General correctness -----
|
||||
eqeqeq: ["error", "smart"],
|
||||
"no-console": ["warn", { allow: ["warn", "error", "info"] }],
|
||||
"no-debugger": "error",
|
||||
"prefer-const": "warn",
|
||||
},
|
||||
},
|
||||
);
|
||||
15
client/ui/frontend/index.html
Normal file
15
client/ui/frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetBird</title>
|
||||
<style>
|
||||
html, body { background: #181A1D; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
69
client/ui/frontend/package.json
Normal file
69
client/ui/frontend/package.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "netbird-ui",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:dev": "tsc && vite build --minify false --mode development",
|
||||
"build": "tsc && vite build --mode production",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bindings": "cd .. && wails3 generate bindings -clean=true -ts",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||
"check": "pnpm lint && pnpm typecheck && pnpm format:check",
|
||||
"check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@wailsio/runtime": "latest",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"i18next": "^26.2.0",
|
||||
"lucide-react": "^0.566.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-router-dom": "^7.1.3",
|
||||
"react-virtuoso": "^4.12.5",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.6.0",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
"packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f"
|
||||
}
|
||||
5240
client/ui/frontend/pnpm-lock.yaml
generated
Normal file
5240
client/ui/frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
client/ui/frontend/pnpm-workspace.yaml
Normal file
2
client/ui/frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
6
client/ui/frontend/postcss.config.js
Normal file
6
client/ui/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
61
client/ui/frontend/src/app.tsx
Normal file
61
client/ui/frontend/src/app.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./globals.css";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx";
|
||||
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
|
||||
import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
|
||||
import ErrorDialog from "@/modules/error/ErrorDialog.tsx";
|
||||
import { AppLayout } from "@/layouts/AppLayout.tsx";
|
||||
import { MainPage } from "@/modules/main/MainPage.tsx";
|
||||
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { welcome } from "@/lib/welcome";
|
||||
import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
|
||||
import { initI18n } from "@/lib/i18n";
|
||||
import { initPlatform } from "@/lib/platform";
|
||||
import { initLogForwarding } from "@/lib/logs";
|
||||
|
||||
// Must run first so even init-time logs reach the Go log pipeline.
|
||||
initLogForwarding();
|
||||
|
||||
welcome();
|
||||
|
||||
Promise.all([
|
||||
initI18n().catch((e) => {
|
||||
console.error("i18n init failed:", e);
|
||||
}),
|
||||
initPlatform().catch((e) => {
|
||||
console.error("platform init failed:", e);
|
||||
}),
|
||||
]).finally(() => {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path={"dialog"}>
|
||||
<Route
|
||||
path={"browser-login"}
|
||||
element={<LoginWaitingForBrowserDialog />}
|
||||
/>
|
||||
<Route path={"install-progress"} element={<UpdateInProgressDialog />} />
|
||||
<Route
|
||||
path={"session-expiration"}
|
||||
element={<SessionExpirationDialog />}
|
||||
/>
|
||||
<Route path={"welcome"} element={<WelcomeDialog />} />
|
||||
<Route path={"error"} element={<ErrorDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path={"settings"} element={<SettingsPage />} />
|
||||
<Route path={"*"} element={<Navigate to={"/"} replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
BIN
client/ui/frontend/src/assets/fonts/inter-variable.ttf
Normal file
BIN
client/ui/frontend/src/assets/fonts/inter-variable.ttf
Normal file
Binary file not shown.
BIN
client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf
Normal file
BIN
client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf
Normal file
Binary file not shown.
BIN
client/ui/frontend/src/assets/img/tray-darwin.png
Normal file
BIN
client/ui/frontend/src/assets/img/tray-darwin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
BIN
client/ui/frontend/src/assets/img/tray-linux.png
Normal file
BIN
client/ui/frontend/src/assets/img/tray-linux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
BIN
client/ui/frontend/src/assets/img/tray-windows.png
Normal file
BIN
client/ui/frontend/src/assets/img/tray-windows.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
19
client/ui/frontend/src/assets/logos/netbird-full.svg
Normal file
19
client/ui/frontend/src/assets/logos/netbird-full.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="133" height="23" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_0_3)">
|
||||
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#F2F2F2"/>
|
||||
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#F2F2F2"/>
|
||||
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#F2F2F2"/>
|
||||
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#F2F2F2"/>
|
||||
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#F2F2F2"/>
|
||||
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#F2F2F2"/>
|
||||
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#F2F2F2"/>
|
||||
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
|
||||
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_3">
|
||||
<rect width="132.72" height="22.5186" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
5
client/ui/frontend/src/assets/logos/netbird.svg
Normal file
5
client/ui/frontend/src/assets/logos/netbird.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 500 B |
43
client/ui/frontend/src/components/Badge.tsx
Normal file
43
client/ui/frontend/src/components/Badge.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { forwardRef, type ComponentType, type HTMLAttributes } from "react";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger";
|
||||
|
||||
type Props = HTMLAttributes<HTMLSpanElement> & {
|
||||
variant?: BadgeVariant;
|
||||
icon?: ComponentType<LucideProps>;
|
||||
iconSize?: number;
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
info: "bg-sky-900 border border-sky-700 text-sky-200",
|
||||
neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200",
|
||||
brand: "bg-netbird/15 border border-netbird/30 text-netbird",
|
||||
success: "bg-green-900 border border-green-700 text-green-200",
|
||||
warning: "bg-yellow-900 border border-yellow-700 text-yellow-200",
|
||||
danger: "bg-red-900 border border-red-700 text-red-200",
|
||||
};
|
||||
|
||||
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
|
||||
{ variant = "info", icon: Icon, iconSize = 10, className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative top-px inline-flex items-center gap-1 rounded-full px-1.5 py-[0.15rem]",
|
||||
"shrink-0 text-[0.64rem] font-semibold leading-none",
|
||||
VARIANT_CLASSES[variant],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{Icon && <Icon size={iconSize} aria-hidden={"true"} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export default Badge;
|
||||
126
client/ui/frontend/src/components/CopyToClipboard.tsx
Normal file
126
client/ui/frontend/src/components/CopyToClipboard.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const VARIANT_HOVER = {
|
||||
default: "group-hover/copy:[&_*]:text-nb-gray-300",
|
||||
bright: "group-hover/copy:[&_*]:text-nb-gray-200",
|
||||
} as const;
|
||||
|
||||
type CopyToClipboardVariant = keyof typeof VARIANT_HOVER;
|
||||
|
||||
type CopyToClipboardProps = {
|
||||
children: ReactNode;
|
||||
message?: string;
|
||||
size?: number;
|
||||
iconAlignment?: "left" | "right";
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
variant?: CopyToClipboardVariant;
|
||||
"aria-label"?: string;
|
||||
tabIndex?: number;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
export const CopyToClipboard = ({
|
||||
children,
|
||||
message,
|
||||
size = 10,
|
||||
iconAlignment = "right",
|
||||
className,
|
||||
iconClassName,
|
||||
alwaysShowIcon = false,
|
||||
variant = "default",
|
||||
"aria-label": ariaLabel,
|
||||
tabIndex = 0,
|
||||
onKeyDown,
|
||||
}: CopyToClipboardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const wrapperRef = useRef<HTMLButtonElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const text = message ?? wrapperRef.current?.innerText ?? "";
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 500);
|
||||
} catch (e) {
|
||||
console.warn("copy to clipboard failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedLabel =
|
||||
ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy"));
|
||||
|
||||
return (
|
||||
<button
|
||||
type={"button"}
|
||||
ref={wrapperRef}
|
||||
onClick={handleClick}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={tabIndex}
|
||||
aria-label={resolvedLabel}
|
||||
aria-live={"polite"}
|
||||
className={cn(
|
||||
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative min-w-0 truncate",
|
||||
"[&_*]:transition-colors",
|
||||
VARIANT_HOVER[variant],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"relative right-[1px] top-[2px] inline-flex shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
iconClassName,
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
size={size}
|
||||
className={cn(
|
||||
"text-nb-gray-100",
|
||||
!copied && "hidden",
|
||||
!alwaysShowIcon && !copied && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Copy
|
||||
size={size}
|
||||
className={cn(
|
||||
"text-nb-gray-100 group-hover/copy:opacity-100",
|
||||
copied && "hidden",
|
||||
!alwaysShowIcon && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
233
client/ui/frontend/src/components/DropdownMenu.tsx
Normal file
233
client/ui/frontend/src/components/DropdownMenu.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const menuItemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
|
||||
danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
});
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
}
|
||||
>(({ className, inset, children, variant, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-3 pr-2 text-sm outline-none",
|
||||
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className={"ml-auto h-4 w-4"} aria-hidden={"true"} />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-nb-gray-900 bg-nb-gray-930 p-1 text-nb-gray-200 shadow-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
>(({ className, inset, variant, onClick, href, target, rel, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm outline-none",
|
||||
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (href) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{href ? (
|
||||
<a href={href} target={target} rel={rel} className={"flex w-full items-center gap-3"}>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className={"h-4 w-4"} />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className={"h-2 w-2 fill-current"} />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-nb-gray-200",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-nb-gray-910", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
235
client/ui/frontend/src/components/LanguagePicker.tsx
Normal file
235
client/ui/frontend/src/components/LanguagePicker.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
import { type LanguageCode, type Language } from "@bindings/i18n/models.js";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
|
||||
|
||||
const labelFor = (lang: Language): string =>
|
||||
lang.englishName && lang.englishName !== lang.displayName
|
||||
? `${lang.displayName} (${lang.englishName})`
|
||||
: lang.displayName;
|
||||
|
||||
export function LanguagePicker() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [languages, setLanguages] = useState<Language[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const isFocusVisible = useFocusVisible();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadLanguages()
|
||||
.then((list) => {
|
||||
if (!cancelled) setLanguages(list);
|
||||
})
|
||||
.catch((err: unknown) => console.error("load languages failed", err));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)),
|
||||
[languages],
|
||||
);
|
||||
|
||||
const current = useMemo(
|
||||
() =>
|
||||
languages.find((l) => l.code === i18n.language) ??
|
||||
languages.find((l) => l.code === "en"),
|
||||
[languages, i18n.language],
|
||||
);
|
||||
|
||||
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (open) return;
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const select = async (code: string) => {
|
||||
setOpen(false);
|
||||
if (busy || code === i18n.language) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await Preferences.SetLanguage(code as LanguageCode);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex items-center justify-between gap-6"}>
|
||||
<div className={"max-w-md flex-1"}>
|
||||
<Label as={"div"}>{t("settings.general.language.label")}</Label>
|
||||
<HelpText margin={false}>{t("settings.general.language.help")}</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
disabled={busy || languages.length === 0}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
aria-label={t("settings.general.language.label")}
|
||||
aria-haspopup={"listbox"}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"inline-flex h-[40px] min-w-[240px] items-center gap-2 px-3",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<LanguagesIcon
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
<span className={"flex-1 truncate text-left"}>
|
||||
{current ? labelFor(current) : "—"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-400"}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align={"start"}
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)]",
|
||||
"z-50 rounded-lg border border-nb-gray-850 bg-nb-gray-920 p-1 shadow-lg",
|
||||
"data-[side=bottom]:origin-top data-[side=top]:origin-bottom",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=open]:fade-in-0",
|
||||
"data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-1",
|
||||
"data-[side=top]:slide-in-from-bottom-1",
|
||||
"duration-150 ease-out",
|
||||
)}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
"[&_[cmdk-input-wrapper]]:flex [&_[cmdk-input-wrapper]]:items-center",
|
||||
)}
|
||||
>
|
||||
<div className={"px-1 pb-1"}>
|
||||
<div
|
||||
role={"search"}
|
||||
className={"group flex h-8 items-center gap-2 px-1"}
|
||||
>
|
||||
<Search
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder={t("settings.general.language.search")}
|
||||
aria-label={t("settings.general.language.search")}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
|
||||
"border-none outline-none",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"max-h-64 px-1"}>
|
||||
<Command.List>
|
||||
<Command.Empty>
|
||||
<div
|
||||
className={
|
||||
"px-3 py-4 text-center text-[0.7rem] text-nb-gray-400"
|
||||
}
|
||||
>
|
||||
{t("settings.general.language.empty")}
|
||||
</div>
|
||||
</Command.Empty>
|
||||
|
||||
{sorted.map((lang) => {
|
||||
const checked = lang.code === i18n.language;
|
||||
return (
|
||||
<Command.Item
|
||||
key={lang.code}
|
||||
value={`${lang.displayName} ${lang.englishName} ${lang.code}`}
|
||||
onSelect={() => void select(lang.code)}
|
||||
className={cn(
|
||||
"my-0.5 flex cursor-default items-center gap-2 rounded-md px-2 py-2 outline-none",
|
||||
"text-xs font-semibold text-nb-gray-200",
|
||||
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>
|
||||
{labelFor(lang)}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={
|
||||
"flex w-4 shrink-0 items-center justify-center"
|
||||
}
|
||||
>
|
||||
{checked && (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className={"text-netbird"}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
</Command.List>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
client/ui/frontend/src/components/ManagementServerSwitch.tsx
Normal file
38
client/ui/frontend/src/components/ManagementServerSwitch.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||
import { SwitchItem } from "@/components/switches/SwitchItem";
|
||||
import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup";
|
||||
import { ManagementMode } from "@/hooks/useManagementUrl.ts";
|
||||
|
||||
type Props = {
|
||||
value: ManagementMode;
|
||||
onChange: (mode: ManagementMode) => void;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const itemClass = fullWidth ? "flex-1" : undefined;
|
||||
return (
|
||||
<SwitchItemGroup
|
||||
key={i18n.language}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as ManagementMode)}
|
||||
aria-label={t("settings.general.management.label")}
|
||||
className={fullWidth ? "w-full" : undefined}
|
||||
>
|
||||
<SwitchItem value={ManagementMode.Cloud} className={itemClass}>
|
||||
<img
|
||||
src={netbirdLogo}
|
||||
alt={""}
|
||||
aria-hidden={"true"}
|
||||
className={"aspect-[31/23] h-[0.8rem] shrink-0"}
|
||||
/>
|
||||
{t("settings.general.management.cloud")}
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
|
||||
{t("settings.general.management.selfHosted")}
|
||||
</SwitchItem>
|
||||
</SwitchItemGroup>
|
||||
);
|
||||
};
|
||||
37
client/ui/frontend/src/components/SquareIcon.tsx
Normal file
37
client/ui/frontend/src/components/SquareIcon.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type ComponentType } from "react";
|
||||
import { type LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
|
||||
|
||||
const variantClass: Record<SquareIconVariant, string> = {
|
||||
default: "text-white",
|
||||
info: "text-sky-400",
|
||||
warning: "text-netbird",
|
||||
danger: "text-red-500",
|
||||
};
|
||||
|
||||
type SquareIconProps = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
iconSize?: number;
|
||||
variant?: SquareIconVariant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SquareIcon = ({
|
||||
icon: Icon,
|
||||
iconSize = 18,
|
||||
variant = "default",
|
||||
className,
|
||||
}: SquareIconProps) => (
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"flex h-11 w-11 items-center justify-center rounded-lg border border-nb-gray-900 bg-nb-gray-920",
|
||||
variantClass[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon size={iconSize} />
|
||||
</div>
|
||||
);
|
||||
98
client/ui/frontend/src/components/Tooltip.tsx
Normal file
98
client/ui/frontend/src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import * as RTooltip from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
side?: RTooltip.TooltipContentProps["side"];
|
||||
align?: RTooltip.TooltipContentProps["align"];
|
||||
delayDuration?: number;
|
||||
sideOffset?: number;
|
||||
alignOffset?: number;
|
||||
interactive?: boolean;
|
||||
keepOpenOnClick?: boolean;
|
||||
contentClassName?: string;
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
content,
|
||||
children,
|
||||
side = "bottom",
|
||||
align = "center",
|
||||
delayDuration = 200,
|
||||
sideOffset = 6,
|
||||
alignOffset = 0,
|
||||
interactive = false,
|
||||
keepOpenOnClick = true,
|
||||
contentClassName,
|
||||
closeDelay = 0,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hoveringRef = useRef(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
if (closeDelay <= 0) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
|
||||
};
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next && keepOpenOnClick && hoveringRef.current) return;
|
||||
if (next) cancelClose();
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<RTooltip.Provider delayDuration={delayDuration} disableHoverableContent={!interactive}>
|
||||
<RTooltip.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<RTooltip.Trigger
|
||||
asChild
|
||||
onPointerEnter={() => {
|
||||
hoveringRef.current = true;
|
||||
cancelClose();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
hoveringRef.current = false;
|
||||
scheduleClose();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RTooltip.Trigger>
|
||||
<RTooltip.Portal>
|
||||
<RTooltip.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
onPointerEnter={interactive ? cancelClose : undefined}
|
||||
onPointerLeave={interactive ? scheduleClose : undefined}
|
||||
onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
|
||||
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
|
||||
!interactive && "pointer-events-none",
|
||||
contentClassName ??
|
||||
"rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</RTooltip.Content>
|
||||
</RTooltip.Portal>
|
||||
</RTooltip.Root>
|
||||
</RTooltip.Provider>
|
||||
);
|
||||
};
|
||||
32
client/ui/frontend/src/components/TruncatedText.tsx
Normal file
32
client/ui/frontend/src/components/TruncatedText.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
className?: string;
|
||||
tooltipContent?: ReactNode;
|
||||
delayDuration?: number;
|
||||
};
|
||||
|
||||
export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [text]);
|
||||
|
||||
const span = (
|
||||
<span ref={ref} className={className}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={tooltipContent ?? text} delayDuration={delayDuration}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
98
client/ui/frontend/src/components/VerticalTabs.tsx
Normal file
98
client/ui/frontend/src/components/VerticalTabs.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { type ComponentType, type ReactNode, forwardRef } from "react";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { type LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
|
||||
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
|
||||
function VerticalTabsRoot({ className, ...props }, ref) {
|
||||
return (
|
||||
<Tabs.Root
|
||||
ref={ref}
|
||||
orientation={"vertical"}
|
||||
className={cn("flex min-h-0 flex-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTabsList(
|
||||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Tabs.List
|
||||
ref={ref}
|
||||
className={cn("flex w-full flex-col gap-1 p-5 pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type TriggerProps = Tabs.TabsTriggerProps & {
|
||||
icon: ComponentType<LucideProps>;
|
||||
title: string;
|
||||
iconSize?: number;
|
||||
adornment?: ReactNode;
|
||||
};
|
||||
|
||||
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTabsTrigger(
|
||||
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const isFocusVisible = useFocusVisible();
|
||||
return (
|
||||
<Tabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex w-full cursor-default items-center gap-3 rounded-lg px-2 py-2.5 text-left outline-none",
|
||||
"transition-colors duration-150",
|
||||
"data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"ml-2 shrink-0 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{adornment && (
|
||||
<div aria-hidden={"true"} className={"ml-auto mr-2 shrink-0"}>
|
||||
{adornment}
|
||||
</div>
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(function VerticalTabsContent(
|
||||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Tabs.Content
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
className={cn("outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });
|
||||
195
client/ui/frontend/src/components/buttons/Button.tsx
Normal file
195
client/ui/frontend/src/components/buttons/Button.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
copy?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
"relative",
|
||||
"cursor-default select-none whitespace-nowrap text-sm font-medium shadow-sm focus:z-10 focus:outline-none focus:ring-2",
|
||||
"inline-flex items-center justify-center gap-2 transition-colors focus:ring-offset-1",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40 dark:ring-offset-neutral-950/50 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900",
|
||||
"enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50",
|
||||
],
|
||||
secondary: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white",
|
||||
],
|
||||
secondaryLighter: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white",
|
||||
],
|
||||
subtle: [
|
||||
"border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40",
|
||||
"dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950",
|
||||
],
|
||||
input: [
|
||||
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80",
|
||||
],
|
||||
dropdown: [
|
||||
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white",
|
||||
],
|
||||
tertiary: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
white: [
|
||||
"border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
outline: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50",
|
||||
],
|
||||
"danger-outline": [
|
||||
"dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20",
|
||||
],
|
||||
"danger-text": [
|
||||
"rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
|
||||
"data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white",
|
||||
],
|
||||
ghost: [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
|
||||
],
|
||||
danger: [
|
||||
"dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20",
|
||||
],
|
||||
},
|
||||
size: {
|
||||
xs: "px-3.5 py-2.5 text-xs",
|
||||
xs2: "px-4 py-[1.1rem] text-[0.78rem] leading-[0]",
|
||||
sm: "px-4 py-[9px] text-sm",
|
||||
md: "px-4 py-[9px]",
|
||||
lg: "px-4 py-[9px] text-lg",
|
||||
},
|
||||
rounded: {
|
||||
true: "rounded-md",
|
||||
false: "",
|
||||
},
|
||||
border: {
|
||||
0: "border",
|
||||
1: "border border-transparent",
|
||||
2: "border border-b-0 border-t-0",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
variant = "default",
|
||||
rounded = true,
|
||||
border = 1,
|
||||
size = "md",
|
||||
stopPropagation = true,
|
||||
type = "button",
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
disabled,
|
||||
copy,
|
||||
loading = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const iconSize = size === "xs" ? 12 : 14;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
tabIndex={0}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant,
|
||||
rounded,
|
||||
border: border ? 1 : 0,
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
if (copy !== undefined) {
|
||||
void navigator.clipboard
|
||||
.writeText(copy)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
.catch((e: unknown) => console.warn("copy to clipboard failed", e));
|
||||
}
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={"absolute inset-0 flex items-center justify-center"}
|
||||
>
|
||||
<Loader2 size={iconSize} className={"animate-spin"} />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("contents", loading && "invisible")}>
|
||||
{copy !== undefined &&
|
||||
(copied ? (
|
||||
<Check size={iconSize} aria-hidden={"true"} />
|
||||
) : (
|
||||
<Copy size={iconSize} aria-hidden={"true"} />
|
||||
))}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default Button;
|
||||
36
client/ui/frontend/src/components/buttons/IconButton.tsx
Normal file
36
client/ui/frontend/src/components/buttons/IconButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { type ButtonHTMLAttributes, type ComponentType, forwardRef } from "react";
|
||||
import { type LucideProps } from "lucide-react";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
icon: ComponentType<LucideProps>;
|
||||
iconSize?: number;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{ icon: Icon, iconSize = 17, iconClassName, className, type = "button", disabled, ...props },
|
||||
ref,
|
||||
) {
|
||||
const isFocusVisible = useFocusVisible();
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cn(
|
||||
"flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={iconSize} className={iconClassName} />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
35
client/ui/frontend/src/components/dialog/ConfirmDialog.tsx
Normal file
35
client/ui/frontend/src/components/dialog/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type ReactNode, forwardRef } from "react";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { isMacOS } from "@/lib/platform.ts";
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
children: ReactNode;
|
||||
"aria-label"?: string;
|
||||
"aria-labelledby"?: string;
|
||||
};
|
||||
|
||||
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(function ConfirmDialog(
|
||||
{ children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<dialog
|
||||
open
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={
|
||||
"wails-draggable static m-0 flex max-h-none w-full max-w-none select-none flex-col items-center border-0 bg-transparent p-0 text-inherit"
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-5 px-8 pb-7 pt-6 text-center",
|
||||
isMacOS() && "pt-10",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
});
|
||||
84
client/ui/frontend/src/components/dialog/ConfirmModal.tsx
Normal file
84
client/ui/frontend/src/components/dialog/ConfirmModal.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Dialog from "@/components/dialog/Dialog";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
|
||||
type ConfirmModalProps = {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export const ConfirmModal = ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger = false,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedCancel = cancelLabel ?? t("common.cancel");
|
||||
|
||||
const srTitle = typeof title === "string" ? title : undefined;
|
||||
const srDescription = typeof description === "string" ? description : undefined;
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && !busy) onCancel();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content
|
||||
maxWidthClass={"max-w-sm"}
|
||||
showClose={false}
|
||||
className={"py-5"}
|
||||
srTitle={srTitle}
|
||||
srDescription={srDescription}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex flex-col gap-5 px-5"}>
|
||||
<div className={"flex flex-col gap-1 pl-1"}>
|
||||
<DialogHeading align={"left"}>{title}</DialogHeading>
|
||||
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions className={"flex-row justify-end gap-2.5"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"sm"}
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{resolvedCancel}
|
||||
</Button>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={danger ? "danger" : "primary"}
|
||||
size={"sm"}
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
159
client/ui/frontend/src/components/dialog/Dialog.tsx
Normal file
159
client/ui/frontend/src/components/dialog/Dialog.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
type HTMLAttributes,
|
||||
} from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export const Root = DialogPrimitive.Root;
|
||||
|
||||
type OverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||
exitAnimation?: boolean;
|
||||
};
|
||||
|
||||
const Overlay = forwardRef<ElementRef<typeof DialogPrimitive.Overlay>, OverlayProps>(
|
||||
function DialogOverlay({ className, exitAnimation = false, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
|
||||
"bg-black/60",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
exitAnimation &&
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
"duration-150 ease-out",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showClose?: boolean;
|
||||
maxWidthClass?: string;
|
||||
exitAnimation?: boolean;
|
||||
srTitle?: string;
|
||||
srDescription?: string;
|
||||
};
|
||||
|
||||
export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
|
||||
function DialogContent(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
showClose = true,
|
||||
maxWidthClass = "max-w-md",
|
||||
exitAnimation = false,
|
||||
srTitle,
|
||||
srDescription,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<Overlay exitAnimation={exitAnimation}>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-[52] mx-auto w-full outline-none ring-0",
|
||||
"focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1",
|
||||
exitAnimation &&
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-left-1",
|
||||
"duration-150 ease-out",
|
||||
maxWidthClass,
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogPrimitive.Title>
|
||||
{srTitle ?? t("common.netbird")}
|
||||
</DialogPrimitive.Title>
|
||||
</VisuallyHidden>
|
||||
{srDescription && (
|
||||
<VisuallyHidden asChild>
|
||||
<DialogPrimitive.Description>
|
||||
{srDescription}
|
||||
</DialogPrimitive.Description>
|
||||
</VisuallyHidden>
|
||||
)}
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
"absolute right-3 top-3 z-10 rounded-md p-3 transition-colors",
|
||||
"text-nb-gray-300 hover:text-nb-gray-100",
|
||||
"focus:outline-none disabled:pointer-events-none",
|
||||
)}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className={"h-4 w-4"} aria-hidden={"true"} />
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</Overlay>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const Title = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Title>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(function DialogTitle({ className, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-md font-semibold leading-none tracking-tight text-nb-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const Description = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Description>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(function DialogDescription({ className, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("mt-2 text-sm leading-snug text-nb-gray-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type FooterProps = HTMLAttributes<HTMLDivElement> & {
|
||||
separator?: boolean;
|
||||
};
|
||||
|
||||
export const Footer = ({ className, separator = true, ...props }: FooterProps) => (
|
||||
<div className={cn(separator && "mt-6 border-t border-nb-gray-900")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-3 sm:flex-row sm:justify-end",
|
||||
"[&>*]:w-full sm:[&>*]:w-auto",
|
||||
"px-8 pt-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
13
client/ui/frontend/src/components/dialog/DialogActions.tsx
Normal file
13
client/ui/frontend/src/components/dialog/DialogActions.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type DialogActionsProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DialogActions = ({ children, className }: DialogActionsProps) => (
|
||||
<div className={cn("wails-no-draggable mx-auto flex w-full flex-col gap-3", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type DialogAlign = "left" | "center" | "right";
|
||||
|
||||
const alignClass: Record<DialogAlign, string> = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
};
|
||||
|
||||
type DialogDescriptionProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: DialogAlign;
|
||||
};
|
||||
|
||||
export const DialogDescription = ({
|
||||
children,
|
||||
className,
|
||||
align = "center",
|
||||
}: DialogDescriptionProps) => (
|
||||
<p className={cn("w-full select-none text-sm text-nb-gray-300", alignClass[align], className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
35
client/ui/frontend/src/components/dialog/DialogHeading.tsx
Normal file
35
client/ui/frontend/src/components/dialog/DialogHeading.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type DialogAlign = "left" | "center" | "right";
|
||||
|
||||
const alignClass: Record<DialogAlign, string> = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
};
|
||||
|
||||
type DialogHeadingProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: DialogAlign;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export const DialogHeading = ({
|
||||
children,
|
||||
className,
|
||||
align = "center",
|
||||
id,
|
||||
}: DialogHeadingProps) => (
|
||||
<h2
|
||||
id={id}
|
||||
className={cn(
|
||||
"w-full select-none text-base font-semibold text-nb-gray-50",
|
||||
alignClass[align],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
|
||||
}
|
||||
|
||||
export const DaemonOutdatedOverlay = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isDaemonOutdated } = useStatus();
|
||||
|
||||
if (!isDaemonOutdated) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"wails-draggable fixed inset-0 z-[100] flex cursor-default select-none items-center justify-center bg-nb-gray-950 backdrop-blur-sm"
|
||||
}
|
||||
>
|
||||
<div className={"flex max-w-lg flex-col items-center gap-5 px-8 text-center"}>
|
||||
<div
|
||||
className={
|
||||
"flex h-11 w-11 items-center justify-center rounded-xl border border-nb-gray-900 bg-nb-gray-920 text-amber-500"
|
||||
}
|
||||
>
|
||||
<AlertTriangleIcon size={20} />
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<p className={"text-base font-medium text-nb-gray-50"}>
|
||||
{t("daemon.outdated.title")}
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable"}>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
|
||||
<DownloadIcon size={14} />
|
||||
{t("update.card.getInstaller")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircleIcon, BookText } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
const DOCS_URL = "https://docs.netbird.io/how-to/installation";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
|
||||
}
|
||||
|
||||
export const DaemonUnavailableOverlay = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isDaemonUnavailable } = useStatus();
|
||||
|
||||
if (!isDaemonUnavailable) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"wails-draggable fixed inset-0 z-[100] flex cursor-default select-none items-center justify-center bg-nb-gray-950 backdrop-blur-sm"
|
||||
}
|
||||
>
|
||||
<div className={"flex max-w-lg flex-col items-center gap-5 px-8 text-center"}>
|
||||
<div
|
||||
className={
|
||||
"flex h-11 w-11 items-center justify-center rounded-xl border border-nb-gray-900 bg-nb-gray-920 text-red-500"
|
||||
}
|
||||
>
|
||||
<AlertCircleIcon size={20} />
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<p className={"text-base font-medium text-nb-gray-50"}>
|
||||
{t("daemon.unavailable.title")}
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-300"}>
|
||||
{t("daemon.unavailable.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable"}>
|
||||
<Button variant={"secondary"} size={"xs"} onClick={() => openUrl(DOCS_URL)}>
|
||||
<BookText size={14} />
|
||||
{t("daemon.unavailable.docsLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
client/ui/frontend/src/components/empty-state/EmptyState.tsx
Normal file
31
client/ui/frontend/src/components/empty-state/EmptyState.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type ComponentType } from "react";
|
||||
import { type LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { isMacOS } from "@/lib/platform";
|
||||
|
||||
// Knob to shift the centered main-window content up/down together.
|
||||
export const contentVerticalOffset = (): string => (isMacOS() ? "0.6rem" : "-1.4rem");
|
||||
export const contentTop = (base: string) => `calc(${base} + ${contentVerticalOffset()})`;
|
||||
|
||||
type Props = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const EmptyState = ({ icon, title, description, className }: Props) => {
|
||||
return (
|
||||
<div className={cn("py-12 text-center", className)}>
|
||||
<div
|
||||
className={"relative mx-auto flex max-w-sm flex-col items-center justify-start"}
|
||||
style={{ top: contentTop("7.8rem") }}
|
||||
>
|
||||
<SquareIcon icon={icon} className={"mb-3"} />
|
||||
<p className={"mb-1 text-[0.95rem] font-medium text-nb-gray-200"}>{title}</p>
|
||||
{description && <p className={"text-sm text-nb-gray-350"}>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
client/ui/frontend/src/components/empty-state/NoResults.tsx
Normal file
22
client/ui/frontend/src/components/empty-state/NoResults.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { type ComponentType } from "react";
|
||||
import { FunnelXIcon, type LucideProps } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
type Props = {
|
||||
icon?: ComponentType<LucideProps>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title ?? t("common.noResults.title")}
|
||||
description={description ?? t("common.noResults.description")}
|
||||
className={"pointer-events-none relative -top-[3.8rem]"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { GlobeOffIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
export const NotConnectedState = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={"relative top-[3rem] w-full"}>
|
||||
<EmptyState
|
||||
icon={GlobeOffIcon}
|
||||
title={t("notConnected.title")}
|
||||
description={t("notConnected.description")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
374
client/ui/frontend/src/components/inputs/Input.tsx
Normal file
374
client/ui/frontend/src/components/inputs/Input.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
type InputHTMLAttributes,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
|
||||
type InputVariants = VariantProps<typeof inputVariants>;
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, InputVariants {
|
||||
label?: string;
|
||||
customPrefix?: ReactNode;
|
||||
customSuffix?: ReactNode;
|
||||
maxWidthClass?: string;
|
||||
icon?: ReactNode;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
prefixClassName?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
copy?: boolean;
|
||||
}
|
||||
|
||||
const inputVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
darker: [
|
||||
"border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
error: [
|
||||
"border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10",
|
||||
],
|
||||
warning: [
|
||||
"border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10",
|
||||
],
|
||||
},
|
||||
prefixSuffixVariant: {
|
||||
default: [
|
||||
"border-neutral-200 text-nb-gray-300 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
],
|
||||
error: ["border-red-500 text-nb-gray-300 text-red-500 dark:bg-nb-gray-900"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number {
|
||||
const stepAttr = el.step === "" ? 1 : Number(el.step);
|
||||
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
|
||||
const min = el.min === "" ? -Infinity : Number(el.min);
|
||||
const max = el.max === "" ? Infinity : Number(el.max);
|
||||
const current = el.value === "" ? 0 : Number(el.value);
|
||||
let next = (Number.isFinite(current) ? current : 0) + delta * step;
|
||||
if (next < min) next = min;
|
||||
if (next > max) next = max;
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildInputClassName(
|
||||
opts: Readonly<{
|
||||
variant: InputVariants["variant"];
|
||||
hasCustomPrefix: boolean;
|
||||
hasSuffix: boolean;
|
||||
hasIcon: boolean;
|
||||
readOnly?: boolean;
|
||||
showStepper: boolean;
|
||||
className?: string;
|
||||
}>,
|
||||
): string {
|
||||
return cn(
|
||||
inputVariants({ variant: opts.variant }),
|
||||
"flex h-[40px] w-full select-text rounded-md bg-white px-3 py-2 text-sm",
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
opts.hasCustomPrefix && "!rounded-l-none !border-l-0",
|
||||
opts.hasSuffix && "!pr-9",
|
||||
opts.hasIcon && "!pl-10",
|
||||
"border",
|
||||
opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350",
|
||||
opts.showStepper &&
|
||||
"!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
|
||||
opts.className,
|
||||
);
|
||||
}
|
||||
|
||||
function InputAffix({
|
||||
content,
|
||||
error,
|
||||
disabled,
|
||||
className,
|
||||
}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
|
||||
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||
"items-center whitespace-nowrap border",
|
||||
disabled && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 flex h-full items-center pl-3 text-xs leading-[0] dark:text-nb-gray-300",
|
||||
disabled && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputSuffixSlot({
|
||||
suffix,
|
||||
disabled,
|
||||
}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 top-0 flex h-full select-none items-center pr-3 text-xs leading-[0] dark:text-nb-gray-300",
|
||||
disabled && "opacity-30",
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberStepper({
|
||||
error,
|
||||
disabled,
|
||||
onStep,
|
||||
}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[40px] shrink-0 flex-col overflow-hidden",
|
||||
"rounded-r-md border border-l-0",
|
||||
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
error && "dark:border-red-500",
|
||||
disabled && "pointer-events-none opacity-40",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={-1}
|
||||
aria-label={t("common.increase")}
|
||||
onClick={() => onStep(1)}
|
||||
className={
|
||||
"flex w-9 flex-1 cursor-default items-center justify-center text-nb-gray-300 transition-colors hover:bg-nb-gray-800"
|
||||
}
|
||||
>
|
||||
<ChevronUp size={12} aria-hidden={"true"} />
|
||||
</button>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={-1}
|
||||
aria-label={t("common.decrease")}
|
||||
onClick={() => onStep(-1)}
|
||||
className={cn(
|
||||
"flex w-9 flex-1 cursor-default items-center justify-center text-nb-gray-300 transition-colors hover:bg-nb-gray-800",
|
||||
"border-t border-neutral-200 dark:border-nb-gray-700",
|
||||
)}
|
||||
>
|
||||
<ChevronDown size={12} aria-hidden={"true"} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldMessage({
|
||||
id,
|
||||
error,
|
||||
warning,
|
||||
}: Readonly<{ id?: string; error?: string; warning?: string }>) {
|
||||
if (!error && !warning) return null;
|
||||
return (
|
||||
<span
|
||||
id={id}
|
||||
role={error ? "alert" : "status"}
|
||||
className={cn(
|
||||
"mt-2 inline-flex items-center gap-1 text-xs",
|
||||
error ? "text-red-500" : "text-orange-400",
|
||||
)}
|
||||
>
|
||||
{error ?? warning}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
label,
|
||||
customSuffix,
|
||||
customPrefix,
|
||||
icon,
|
||||
maxWidthClass = "",
|
||||
error,
|
||||
warning,
|
||||
variant = "default",
|
||||
prefixClassName,
|
||||
showPasswordToggle = false,
|
||||
copy = false,
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isPasswordType = type === "password";
|
||||
const inputType = isPasswordType && showPassword ? "text" : type;
|
||||
const isNumber = type === "number";
|
||||
|
||||
const reactId = useId();
|
||||
const fallbackId = `input-${reactId}`;
|
||||
const inputId = id ?? (label ? fallbackId : undefined);
|
||||
const messageId = error || warning ? `${inputId ?? fallbackId}-message` : undefined;
|
||||
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const internalRef = useRef<HTMLInputElement | null>(null);
|
||||
const setRefs = (el: HTMLInputElement | null) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") ref(el);
|
||||
else if (ref) ref.current = el;
|
||||
};
|
||||
|
||||
const stepBy = (delta: 1 | -1) => {
|
||||
const el = internalRef.current;
|
||||
if (!el || el.disabled || el.readOnly) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
globalThis.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
const next = computeNextStepValue(el, delta);
|
||||
setter?.call(el, String(next));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
const passwordToggle =
|
||||
isPasswordType && showPasswordToggle ? (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
aria-label={t("common.togglePasswordVisibility")}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={18} aria-hidden={"true"} />
|
||||
) : (
|
||||
<Eye size={18} aria-hidden={"true"} />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const onCopy = async () => {
|
||||
const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value);
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch (e) {
|
||||
console.warn("copy to clipboard failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToggle = copy ? (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onCopy}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={16} aria-hidden={"true"} />
|
||||
) : (
|
||||
<Copy size={16} aria-hidden={"true"} />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const suffix = passwordToggle || copyToggle || customSuffix;
|
||||
const showStepper = isNumber;
|
||||
const warningVariant = warning ? "warning" : variant;
|
||||
const resolvedVariant = error ? "error" : warningVariant;
|
||||
|
||||
const inputClassName = buildInputClassName({
|
||||
variant: resolvedVariant,
|
||||
hasCustomPrefix: !!customPrefix,
|
||||
hasSuffix: !!suffix,
|
||||
hasIcon: !!icon,
|
||||
readOnly: props.readOnly,
|
||||
showStepper,
|
||||
className,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={"flex w-full min-w-0 flex-col"}>
|
||||
{label && <Label htmlFor={inputId}>{label}</Label>}
|
||||
<div className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
|
||||
{customPrefix && (
|
||||
<InputAffix
|
||||
content={customPrefix}
|
||||
error={error}
|
||||
disabled={props.disabled}
|
||||
className={prefixClassName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{icon && <InputIconSlot icon={icon} disabled={props.disabled} />}
|
||||
|
||||
<div className={"relative flex min-w-0 flex-grow"}>
|
||||
<input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
ref={setRefs}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={
|
||||
messageId
|
||||
? [props["aria-describedby"], messageId].filter(Boolean).join(" ")
|
||||
: props["aria-describedby"]
|
||||
}
|
||||
{...props}
|
||||
className={inputClassName}
|
||||
/>
|
||||
|
||||
{suffix && <InputSuffixSlot suffix={suffix} disabled={props.disabled} />}
|
||||
</div>
|
||||
|
||||
{showStepper && (
|
||||
<NumberStepper error={error} disabled={props.disabled} onStep={stepBy} />
|
||||
)}
|
||||
</div>
|
||||
<FieldMessage id={messageId} error={error} warning={warning} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Input;
|
||||
59
client/ui/frontend/src/components/inputs/SearchInput.tsx
Normal file
59
client/ui/frontend/src/components/inputs/SearchInput.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
iconSize?: number;
|
||||
shortcut?: ReactNode;
|
||||
};
|
||||
|
||||
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
|
||||
{ iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role={"search"}
|
||||
className={cn("flex h-10 items-center gap-2 px-1", disabled && "opacity-50")}
|
||||
>
|
||||
<SearchIcon
|
||||
size={iconSize}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-300"}
|
||||
/>
|
||||
<input
|
||||
ref={ref}
|
||||
type={"search"}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
|
||||
autoCorrect={"off"}
|
||||
autoCapitalize={"off"}
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"border-none outline-none",
|
||||
disabled && "cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{shortcut && (
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"shrink-0 select-none",
|
||||
"inline-flex items-center justify-center",
|
||||
"h-5 min-w-[20px] rounded px-1.5",
|
||||
"border border-nb-gray-850 bg-nb-gray-920",
|
||||
"text-[10px] font-medium text-nb-gray-400",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
{shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
102
client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx
Normal file
102
client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { ToggleSwitch } from "@/components/switches/ToggleSwitch";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface Props {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
helpText?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
dataCy?: string;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
textWrapperClassName?: string;
|
||||
}
|
||||
|
||||
export default function FancyToggleSwitch({
|
||||
value,
|
||||
onChange,
|
||||
helpText,
|
||||
label,
|
||||
children,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
dataCy,
|
||||
className,
|
||||
labelClassName,
|
||||
textWrapperClassName = "max-w-lg",
|
||||
}: Readonly<Props>) {
|
||||
const switchId = React.useId();
|
||||
const descriptionId = React.useId();
|
||||
|
||||
if (loading) {
|
||||
const shimmer =
|
||||
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
|
||||
return (
|
||||
<div
|
||||
role={"status"}
|
||||
aria-busy={"true"}
|
||||
aria-live={"polite"}
|
||||
className={cn("inline-block w-full text-left", className)}
|
||||
>
|
||||
<div className={"flex justify-between gap-10"}>
|
||||
<div className={cn(textWrapperClassName)}>
|
||||
<Label className={labelClassName}>
|
||||
<span className={shimmer}>{label}</span>
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
<span className={cn(shimmer, "text-[0.6rem] leading-relaxed")}>
|
||||
{helpText}
|
||||
</span>
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={"h-[24px] w-[44px] animate-pulse rounded-full bg-[#25282d]"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...(disabled ? { inert: "" } : {})}
|
||||
className={cn(
|
||||
"relative z-[1] cursor-default transition-all duration-300",
|
||||
"inline-block w-full text-left",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex justify-between gap-10"}>
|
||||
<div className={cn(textWrapperClassName)}>
|
||||
<Label htmlFor={switchId} className={labelClassName}>
|
||||
{label}
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
<span id={descriptionId}>{helpText}</span>
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<ToggleSwitch
|
||||
id={switchId}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
dataCy={dataCy}
|
||||
aria-describedby={helpText ? descriptionId : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{children && value ? <div className={"mt-4"}>{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
client/ui/frontend/src/components/switches/SwitchItem.tsx
Normal file
42
client/ui/frontend/src/components/switches/SwitchItem.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { motion } from "framer-motion";
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SwitchItem = ({ value, children, className }: Props) => {
|
||||
const { value: activeValue, layoutId } = useSwitchItemGroup();
|
||||
const active = activeValue === value;
|
||||
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center gap-1 rounded-md px-3.5 py-2 text-xs font-semibold",
|
||||
"cursor-default outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
active
|
||||
? "text-nb-gray-100"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-200 active:text-nb-gray-100",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={layoutId}
|
||||
className={"absolute inset-0 rounded-md bg-nb-gray-700"}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
<span className={"relative inline-flex items-center justify-center gap-1"}>
|
||||
{children}
|
||||
</span>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { createContext, type ReactNode, useContext, useId, useMemo } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type SwitchItemGroupContextValue = {
|
||||
value: string;
|
||||
layoutId: string;
|
||||
};
|
||||
|
||||
const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(null);
|
||||
|
||||
export const useSwitchItemGroup = () => {
|
||||
const ctx = useContext(SwitchItemGroupContext);
|
||||
if (!ctx) {
|
||||
throw new Error("SwitchItem must be used inside a SwitchItemGroup");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
"aria-labelledby"?: string;
|
||||
};
|
||||
|
||||
export const SwitchItemGroup = ({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
className,
|
||||
disabled = false,
|
||||
"aria-label": ariaLabel,
|
||||
"aria-labelledby": ariaLabelledBy,
|
||||
}: Props) => {
|
||||
const layoutId = useId();
|
||||
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
|
||||
|
||||
return (
|
||||
<SwitchItemGroupContext.Provider value={contextValue}>
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={cn(
|
||||
"flex shrink-0 overflow-hidden rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</RadioGroup.Root>
|
||||
</SwitchItemGroupContext.Provider>
|
||||
);
|
||||
};
|
||||
77
client/ui/frontend/src/components/switches/ToggleSwitch.tsx
Normal file
77
client/ui/frontend/src/components/switches/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type SwitchVariants = VariantProps<typeof switchVariants>;
|
||||
|
||||
const switchVariants = cva("", {
|
||||
variants: {
|
||||
size: {
|
||||
default: "h-[24px] w-[44px]",
|
||||
small: "h-[18px] w-[36px]",
|
||||
large: "h-[36px] w-[66px]",
|
||||
},
|
||||
variant: {
|
||||
default: [
|
||||
"dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
"dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
|
||||
"data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300",
|
||||
],
|
||||
"red-green": [
|
||||
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
"dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
|
||||
"data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
|
||||
],
|
||||
red: [
|
||||
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
"dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
|
||||
"data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
|
||||
],
|
||||
},
|
||||
"thumb-size": {
|
||||
default:
|
||||
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0",
|
||||
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ToggleSwitch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
|
||||
SwitchVariants & { dataCy?: string }
|
||||
>(({ className, size = "default", variant = "default", dataCy, disabled, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cn(
|
||||
"wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
{...props}
|
||||
data-cy={dataCy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
switchVariants({ "thumb-size": size }),
|
||||
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform dark:bg-white",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { ToggleSwitch };
|
||||
24
client/ui/frontend/src/components/typography/HelpText.tsx
Normal file
24
client/ui/frontend/src/components/typography/HelpText.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = {
|
||||
children?: ReactNode;
|
||||
margin?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => (
|
||||
<span
|
||||
className={cn(
|
||||
"block text-[.81rem] font-light tracking-wide transition-all duration-300 dark:text-nb-gray-300",
|
||||
margin && "mb-2",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export default HelpText;
|
||||
42
client/ui/frontend/src/components/typography/Label.tsx
Normal file
42
client/ui/frontend/src/components/typography/Label.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { type ComponentPropsWithoutRef, forwardRef, type Ref } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const labelVariants = cva(
|
||||
"mb-1.5 inline-block flex items-center gap-2 text-sm font-medium leading-none tracking-wider peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-nb-gray-100",
|
||||
);
|
||||
|
||||
type LabelProps = ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants> & {
|
||||
as?: "label" | "div";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
|
||||
{ className, as = "label", disabled = false, children, ...props },
|
||||
ref,
|
||||
) {
|
||||
const classes = cn(
|
||||
labelVariants(),
|
||||
className,
|
||||
"select-none transition-all duration-300",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
);
|
||||
|
||||
if (as === "div") {
|
||||
return (
|
||||
<div ref={ref as Ref<HTMLDivElement>} className={classes}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelPrimitive.Root ref={ref as Ref<HTMLLabelElement>} className={classes} {...props}>
|
||||
{children}
|
||||
</LabelPrimitive.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export default Label;
|
||||
114
client/ui/frontend/src/contexts/ClientVersionContext.tsx
Normal file
114
client/ui/frontend/src/contexts/ClientVersionContext.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
|
||||
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
|
||||
import type { State as UpdateState } from "@bindings/updater/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const isDaemonUnavailable = (e: unknown): boolean => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return msg.includes("code = Unavailable");
|
||||
};
|
||||
|
||||
type ClientVersionContextValue = {
|
||||
updateAvailable: boolean;
|
||||
updateVersion: string | null;
|
||||
enforced: boolean;
|
||||
installing: boolean;
|
||||
triggerUpdate: () => void;
|
||||
updating: boolean;
|
||||
};
|
||||
|
||||
const EVENT_UPDATE_STATE = "netbird:update:state";
|
||||
|
||||
const emptyState: UpdateState = {
|
||||
available: false,
|
||||
version: "",
|
||||
enforced: false,
|
||||
installing: false,
|
||||
};
|
||||
|
||||
const ClientVersionContext = createContext<ClientVersionContextValue | null>(null);
|
||||
|
||||
export const useClientVersion = () => {
|
||||
const ctx = useContext(ClientVersionContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useClientVersion must be used inside ClientVersionProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [state, setState] = useState<UpdateState>(emptyState);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
UpdateSvc.GetState()
|
||||
.then((s) => {
|
||||
if (cancelled || !s) return;
|
||||
setState(s);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled || isDaemonUnavailable(e)) return;
|
||||
void errorDialog({
|
||||
Title: i18next.t("update.error.loadStateTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
});
|
||||
const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => {
|
||||
if (ev?.data) setState(ev.data);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
off?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const prevInstallingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (state.installing && !prevInstallingRef.current) {
|
||||
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
|
||||
}
|
||||
prevInstallingRef.current = state.installing;
|
||||
}, [state.installing, state.version]);
|
||||
|
||||
const triggerUpdate = useCallback(() => {
|
||||
setUpdating(true);
|
||||
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
|
||||
UpdateSvc.Trigger()
|
||||
.catch(async (e) => {
|
||||
if (isDaemonUnavailable(e)) return;
|
||||
WindowManager.CloseInstallProgress().catch(console.error);
|
||||
await errorDialog({
|
||||
Title: i18next.t("update.error.triggerTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
})
|
||||
.finally(() => setUpdating(false));
|
||||
}, [state.version]);
|
||||
|
||||
const value = useMemo<ClientVersionContextValue>(
|
||||
() => ({
|
||||
updateAvailable: state.available,
|
||||
updateVersion: state.version || null,
|
||||
enforced: state.enforced,
|
||||
installing: state.installing,
|
||||
triggerUpdate,
|
||||
updating,
|
||||
}),
|
||||
[state, triggerUpdate, updating],
|
||||
);
|
||||
|
||||
return <ClientVersionContext.Provider value={value}>{children}</ClientVersionContext.Provider>;
|
||||
};
|
||||
314
client/ui/frontend/src/contexts/DebugBundleContext.tsx
Normal file
314
client/ui/frontend/src/contexts/DebugBundleContext.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { startConnection } from "@/lib/connection.ts";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
const TRACE_LOG_FILE_COUNT = 5;
|
||||
const PLAIN_LOG_FILE_COUNT = 1;
|
||||
const TRACE_LOG_LEVEL = "trace";
|
||||
const DEFAULT_LOG_LEVEL = "info";
|
||||
|
||||
export type DebugStage =
|
||||
| { kind: "idle" }
|
||||
| { kind: "preparing-trace" }
|
||||
| { kind: "reconnecting" }
|
||||
| { kind: "capturing"; remainingSec: number; totalSec: number }
|
||||
| { kind: "restoring-level" }
|
||||
| { kind: "bundling" }
|
||||
| { kind: "uploading" }
|
||||
| { kind: "cancelling" }
|
||||
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean };
|
||||
|
||||
const sleep = (ms: number, signal: AbortSignal) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException("aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(id);
|
||||
reject(new DOMException("aborted", "AbortError"));
|
||||
};
|
||||
const id = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal.addEventListener("abort", onAbort);
|
||||
});
|
||||
|
||||
const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
|
||||
|
||||
const throwIfAborted = (signal: AbortSignal) => {
|
||||
if (signal.aborted) throw new DOMException("aborted", "AbortError");
|
||||
};
|
||||
|
||||
const setLogLevelBestEffort = async (level: string) => {
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level });
|
||||
} catch (e) {
|
||||
console.warn("[DebugBundle] best-effort set log level failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const stopCaptureBestEffort = async () => {
|
||||
try {
|
||||
await DebugSvc.StopBundleCapture();
|
||||
} catch (e) {
|
||||
console.warn("[DebugBundle] best-effort stop packet capture failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
type LevelState = { original: string; raised: boolean };
|
||||
type CaptureState = { started: boolean };
|
||||
|
||||
type BundleOptions = {
|
||||
trace: boolean;
|
||||
capture: boolean;
|
||||
capturePackets: boolean;
|
||||
hasWindow: boolean;
|
||||
totalSec: number;
|
||||
uploadUrl: string;
|
||||
anonymize: boolean;
|
||||
systemInfo: boolean;
|
||||
};
|
||||
|
||||
const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
|
||||
try {
|
||||
// Mirror the CLI's safety margin: window + 30s, server caps at 10m.
|
||||
await DebugSvc.StartBundleCapture(totalSec + 30);
|
||||
pcap.started = true;
|
||||
} catch (e) {
|
||||
console.warn("[DebugBundle] start packet capture failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupBestEffort = async (pcap: CaptureState, level: LevelState, restoreLevel: boolean) => {
|
||||
if (pcap.started) {
|
||||
await stopCaptureBestEffort();
|
||||
pcap.started = false;
|
||||
}
|
||||
if (restoreLevel && level.raised) {
|
||||
await setLogLevelBestEffort(level.original);
|
||||
}
|
||||
};
|
||||
|
||||
const raiseToTrace = async (
|
||||
signal: AbortSignal,
|
||||
level: LevelState,
|
||||
setStage: (s: DebugStage) => void,
|
||||
) => {
|
||||
setStage({ kind: "preparing-trace" });
|
||||
try {
|
||||
const cur = await DebugSvc.GetLogLevel();
|
||||
if (cur?.level) level.original = cur.level;
|
||||
} catch (e) {
|
||||
console.warn("[DebugBundle] read current log level failed", e);
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL });
|
||||
level.raised = true;
|
||||
};
|
||||
|
||||
const cycleConnection = async (signal: AbortSignal, setStage: (s: DebugStage) => void) => {
|
||||
throwIfAborted(signal);
|
||||
setStage({ kind: "reconnecting" });
|
||||
try {
|
||||
await ConnectionSvc.Down();
|
||||
} catch (e) {
|
||||
console.warn("[DebugBundle] disconnect before capture failed", e);
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
await startConnection(undefined, signal);
|
||||
};
|
||||
|
||||
const restoreLogLevel = async (level: LevelState, setStage: (s: DebugStage) => void) => {
|
||||
setStage({ kind: "restoring-level" });
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: level.original });
|
||||
level.raised = false;
|
||||
} catch (e) {
|
||||
console.warn("[DebugBundle] restore log level failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const waitCaptureWindow = async (
|
||||
signal: AbortSignal,
|
||||
setStage: (s: DebugStage) => void,
|
||||
totalSec: number,
|
||||
) => {
|
||||
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||
setStage({ kind: "capturing", remainingSec: remaining, totalSec });
|
||||
await sleep(1000, signal);
|
||||
}
|
||||
};
|
||||
|
||||
const runBundleFlow = async (
|
||||
signal: AbortSignal,
|
||||
opts: BundleOptions,
|
||||
level: LevelState,
|
||||
pcap: CaptureState,
|
||||
setStage: (s: DebugStage) => void,
|
||||
setLastBundlePath: (p: string) => void,
|
||||
) => {
|
||||
if (opts.trace) {
|
||||
await raiseToTrace(signal, level, setStage);
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (opts.capture) {
|
||||
await cycleConnection(signal, setStage);
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (opts.hasWindow && opts.capturePackets) {
|
||||
await startCaptureBestEffort(opts.totalSec, pcap);
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (opts.hasWindow) {
|
||||
await waitCaptureWindow(signal, setStage, opts.totalSec);
|
||||
}
|
||||
|
||||
if (pcap.started) {
|
||||
await stopCaptureBestEffort();
|
||||
pcap.started = false;
|
||||
}
|
||||
|
||||
if (level.raised) {
|
||||
await restoreLogLevel(level, setStage);
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
setStage({ kind: "bundling" });
|
||||
const logFileCount = opts.trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
|
||||
|
||||
if (opts.uploadUrl) setStage({ kind: "uploading" });
|
||||
const result = await DebugSvc.Bundle({
|
||||
anonymize: opts.anonymize,
|
||||
systemInfo: opts.systemInfo,
|
||||
uploadUrl: opts.uploadUrl,
|
||||
logFileCount,
|
||||
});
|
||||
throwIfAborted(signal);
|
||||
if (result.path) setLastBundlePath(result.path);
|
||||
setStage({ kind: "done", result, uploadAttempted: Boolean(opts.uploadUrl) });
|
||||
};
|
||||
|
||||
const useDebugBundle = () => {
|
||||
const [anonymize, setAnonymize] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(true);
|
||||
const [trace, setTrace] = useState(true);
|
||||
const [capture, setCapture] = useState(false);
|
||||
const [traceMinutes, setTraceMinutes] = useState(1);
|
||||
const [capturePackets, setCapturePackets] = useState(true);
|
||||
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
|
||||
const [lastBundlePath, setLastBundlePath] = useState<string>("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isRunning = stage.kind !== "idle" && stage.kind !== "done";
|
||||
|
||||
const reset = () => setStage({ kind: "idle" });
|
||||
|
||||
const cancel = () => {
|
||||
if (!abortRef.current || abortRef.current.signal.aborted) return;
|
||||
abortRef.current.abort();
|
||||
setStage({ kind: "cancelling" });
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
const signal = ctrl.signal;
|
||||
|
||||
const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
|
||||
const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false };
|
||||
const pcap: CaptureState = { started: false };
|
||||
const opts: BundleOptions = {
|
||||
trace,
|
||||
capture,
|
||||
capturePackets,
|
||||
hasWindow: capture && totalSec > 0,
|
||||
totalSec,
|
||||
uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
|
||||
anonymize,
|
||||
systemInfo,
|
||||
};
|
||||
|
||||
try {
|
||||
await runBundleFlow(signal, opts, level, pcap, setStage, setLastBundlePath);
|
||||
} catch (e) {
|
||||
if (isAbort(e)) {
|
||||
setStage({ kind: "cancelling" });
|
||||
await cleanupBestEffort(pcap, level, true);
|
||||
setStage({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
await cleanupBestEffort(pcap, level, false);
|
||||
setStage({ kind: "idle" });
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.debugBundleTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
if (abortRef.current === ctrl) abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openBundleDir = () => {
|
||||
if (!lastBundlePath) return;
|
||||
DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
|
||||
console.error("[DebugBundleContext] reveal failed", err),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
systemInfo,
|
||||
setSystemInfo,
|
||||
upload,
|
||||
setUpload,
|
||||
trace,
|
||||
setTrace,
|
||||
capture,
|
||||
setCapture,
|
||||
traceMinutes,
|
||||
setTraceMinutes,
|
||||
capturePackets,
|
||||
setCapturePackets,
|
||||
stage,
|
||||
isRunning,
|
||||
lastBundlePath,
|
||||
run,
|
||||
cancel,
|
||||
reset,
|
||||
openBundleDir,
|
||||
};
|
||||
};
|
||||
|
||||
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
|
||||
|
||||
const DebugBundleContext = createContext<DebugBundleContextValue | null>(null);
|
||||
|
||||
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
|
||||
const value = useDebugBundle();
|
||||
return <DebugBundleContext.Provider value={value}>{children}</DebugBundleContext.Provider>;
|
||||
};
|
||||
|
||||
export const useDebugBundleContext = () => {
|
||||
const ctx = useContext(DebugBundleContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
68
client/ui/frontend/src/contexts/DialogContext.tsx
Normal file
68
client/ui/frontend/src/contexts/DialogContext.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
|
||||
|
||||
export type ConfirmOptions = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
type DialogContextValue = {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
|
||||
export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
||||
const resolverRef = useRef<((result: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions) => {
|
||||
setOptions(opts);
|
||||
setOpen(true);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolverRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const settle = (result: boolean) => {
|
||||
resolverRef.current?.(result);
|
||||
resolverRef.current = null;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={value}>
|
||||
{children}
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={options?.title ?? ""}
|
||||
description={options?.description ?? ""}
|
||||
confirmLabel={options?.confirmLabel ?? ""}
|
||||
cancelLabel={options?.cancelLabel}
|
||||
danger={options?.danger}
|
||||
onConfirm={() => settle(true)}
|
||||
onCancel={() => settle(false)}
|
||||
/>
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useConfirm = () => {
|
||||
const ctx = useContext(DialogContext);
|
||||
if (!ctx) throw new Error("useConfirm must be used within a DialogProvider");
|
||||
return ctx.confirm;
|
||||
};
|
||||
24
client/ui/frontend/src/contexts/NavSectionContext.tsx
Normal file
24
client/ui/frontend/src/contexts/NavSectionContext.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export type NavSection = "peers" | "networks";
|
||||
|
||||
type NavSectionContextValue = {
|
||||
section: NavSection;
|
||||
setSection: (s: NavSection) => void;
|
||||
};
|
||||
|
||||
const NavSectionContext = createContext<NavSectionContextValue | null>(null);
|
||||
|
||||
export const useNavSection = (): NavSectionContextValue => {
|
||||
const ctx = useContext(NavSectionContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNavSection must be used inside NavSectionProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [section, setSection] = useState<NavSection>("peers");
|
||||
const value = useMemo<NavSectionContextValue>(() => ({ section, setSection }), [section]);
|
||||
return <NavSectionContext.Provider value={value}>{children}</NavSectionContext.Provider>;
|
||||
};
|
||||
222
client/ui/frontend/src/contexts/NetworksContext.tsx
Normal file
222
client/ui/frontend/src/contexts/NetworksContext.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Networks as NetworksSvc } from "@bindings/services";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
|
||||
// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node.
|
||||
// The daemon may merge a v4+v6 pair into a single comma-joined range string.
|
||||
export const isExitNode = (range: string): boolean =>
|
||||
range.split(",").some((part) => {
|
||||
const trimmed = part.trim();
|
||||
return trimmed === "0.0.0.0/0" || trimmed === "::/0";
|
||||
});
|
||||
|
||||
type NetworksContextValue = {
|
||||
routes: Network[];
|
||||
networkRoutes: Network[];
|
||||
exitNodes: Network[];
|
||||
activeExitNode: Network | null;
|
||||
refresh: () => Promise<void>;
|
||||
toggleNetwork: (id: string, selected: boolean) => Promise<void>;
|
||||
toggleExitNode: (id: string, selected: boolean) => Promise<void>;
|
||||
setNetworksSelected: (ids: string[], selected: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
const NetworksContext = createContext<NetworksContextValue | null>(null);
|
||||
|
||||
export const useNetworks = () => {
|
||||
const ctx = useContext(NetworksContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNetworks must be used inside NetworksProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { status } = useStatus();
|
||||
const [routes, setRoutes] = useState<Network[]>([]);
|
||||
const [pending, setPending] = useState<Map<string, boolean>>(new Map());
|
||||
const pendingRef = useRef(pending);
|
||||
useEffect(() => {
|
||||
pendingRef.current = pending;
|
||||
}, [pending]);
|
||||
|
||||
// Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever.
|
||||
const STUCK_OVERRIDE_MS = 4000;
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const clearTimer = useCallback((id: string) => {
|
||||
const tid = timersRef.current.get(id);
|
||||
if (tid !== undefined) {
|
||||
clearTimeout(tid);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearPendingFor = useCallback(
|
||||
(ids: string[]) => {
|
||||
for (const id of ids) clearTimer(id);
|
||||
setPending((prev) => {
|
||||
if (ids.every((id) => !prev.has(id))) return prev;
|
||||
const next = new Map(prev);
|
||||
for (const id of ids) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[clearTimer],
|
||||
);
|
||||
|
||||
const setPendingFor = useCallback(
|
||||
(updates: Array<[string, boolean]>) => {
|
||||
setPending((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id, sel] of updates) next.set(id, sel);
|
||||
return next;
|
||||
});
|
||||
for (const [id] of updates) {
|
||||
clearTimer(id);
|
||||
timersRef.current.set(
|
||||
id,
|
||||
setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS),
|
||||
);
|
||||
}
|
||||
},
|
||||
[clearTimer, clearPendingFor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
for (const tid of timers.values()) clearTimeout(tid);
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const list = await NetworksSvc.List();
|
||||
setRoutes(list);
|
||||
} catch (e) {
|
||||
console.error("[NetworksContext] refresh failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const networksRevision = status?.networksRevision;
|
||||
useEffect(() => {
|
||||
refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err));
|
||||
}, [refresh, networksRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingRef.current.size === 0) return;
|
||||
const confirmed: string[] = [];
|
||||
for (const r of routes) {
|
||||
const expected = pendingRef.current.get(r.id);
|
||||
if (expected !== undefined && r.selected === expected) {
|
||||
confirmed.push(r.id);
|
||||
}
|
||||
}
|
||||
if (confirmed.length > 0) clearPendingFor(confirmed);
|
||||
}, [routes, clearPendingFor]);
|
||||
|
||||
const mutate = useCallback(
|
||||
async (ids: string[], selected: boolean, rollback: Array<[string, boolean]>) => {
|
||||
try {
|
||||
if (selected) {
|
||||
await NetworksSvc.Select({ networkIds: ids, append: true, all: false });
|
||||
} else {
|
||||
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false });
|
||||
}
|
||||
// Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back.
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setPending((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id] of rollback) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const toggleNetwork = useCallback(
|
||||
async (id: string, selected: boolean) => {
|
||||
const target = !selected;
|
||||
setPendingFor([[id, target]]);
|
||||
await mutate([id], target, [[id, selected]]).catch(() => {});
|
||||
},
|
||||
[mutate, setPendingFor],
|
||||
);
|
||||
|
||||
const setNetworksSelected = useCallback(
|
||||
async (ids: string[], selected: boolean) => {
|
||||
if (ids.length === 0) return;
|
||||
const prevById = new Map(routes.map((r) => [r.id, r.selected]));
|
||||
const rollback: Array<[string, boolean]> = ids.map((id) => [
|
||||
id,
|
||||
prevById.get(id) ?? !selected,
|
||||
]);
|
||||
setPendingFor(ids.map((id) => [id, selected]));
|
||||
await mutate(ids, selected, rollback).catch(() => {});
|
||||
},
|
||||
[mutate, setPendingFor, routes],
|
||||
);
|
||||
|
||||
// Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches.
|
||||
const toggleExitNode = useCallback(
|
||||
async (id: string, selected: boolean) => {
|
||||
const target = !selected;
|
||||
const updates: Array<[string, boolean]> = [[id, target]];
|
||||
const rollback: Array<[string, boolean]> = [[id, selected]];
|
||||
if (target) {
|
||||
for (const r of routes) {
|
||||
if (r.id !== id && isExitNode(r.range) && r.selected) {
|
||||
updates.push([r.id, false]);
|
||||
rollback.push([r.id, true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
setPendingFor(updates);
|
||||
await mutate([id], target, rollback).catch(() => {});
|
||||
},
|
||||
[mutate, setPendingFor, routes],
|
||||
);
|
||||
|
||||
const value = useMemo<NetworksContextValue>(() => {
|
||||
const effective =
|
||||
pending.size === 0
|
||||
? routes
|
||||
: routes.map((r) => {
|
||||
const override = pending.get(r.id);
|
||||
return override === undefined || override === r.selected
|
||||
? r
|
||||
: { ...r, selected: override };
|
||||
});
|
||||
const networkRoutes = effective.filter((r) => !isExitNode(r.range));
|
||||
const exitNodes = effective.filter((r) => isExitNode(r.range));
|
||||
const activeExitNode = exitNodes.find((r) => r.selected) ?? null;
|
||||
return {
|
||||
routes: effective,
|
||||
networkRoutes,
|
||||
exitNodes,
|
||||
activeExitNode,
|
||||
refresh,
|
||||
toggleNetwork,
|
||||
toggleExitNode,
|
||||
setNetworksSelected,
|
||||
};
|
||||
}, [routes, pending, refresh, toggleNetwork, toggleExitNode, setNetworksSelected]);
|
||||
|
||||
return <NetworksContext.Provider value={value}>{children}</NetworksContext.Provider>;
|
||||
};
|
||||
50
client/ui/frontend/src/contexts/PeerDetailContext.tsx
Normal file
50
client/ui/frontend/src/contexts/PeerDetailContext.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
|
||||
type PeerDetailContextValue = {
|
||||
selected: PeerStatus | null;
|
||||
setSelected: (p: PeerStatus | null) => void;
|
||||
};
|
||||
|
||||
const PeerDetailContext = createContext<PeerDetailContextValue | null>(null);
|
||||
|
||||
export const usePeerDetail = (): PeerDetailContextValue => {
|
||||
const ctx = useContext(PeerDetailContext);
|
||||
if (!ctx) {
|
||||
throw new Error("usePeerDetail must be used inside PeerDetailProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [selected, setSelected] = useState<PeerStatus | null>(null);
|
||||
const openerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const select = useCallback((p: PeerStatus | null) => {
|
||||
if (p) {
|
||||
const active = document.activeElement;
|
||||
openerRef.current = active instanceof HTMLElement ? active : null;
|
||||
} else {
|
||||
const opener = openerRef.current;
|
||||
openerRef.current = null;
|
||||
if (opener?.isConnected) {
|
||||
queueMicrotask(() => opener.focus());
|
||||
}
|
||||
}
|
||||
setSelected(p);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<PeerDetailContextValue>(
|
||||
() => ({ selected, setSelected: select }),
|
||||
[selected, select],
|
||||
);
|
||||
return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>;
|
||||
};
|
||||
182
client/ui/frontend/src/contexts/ProfileContext.tsx
Normal file
182
client/ui/frontend/src/contexts/ProfileContext.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
|
||||
|
||||
type ProfileContextValue = {
|
||||
username: string;
|
||||
// activeProfile is the display NAME of the active profile (for rendering
|
||||
// and the "default" check). activeProfileId is its stable on-disk ID, used
|
||||
// as the handle for daemon requests and for active-profile comparisons,
|
||||
// since display names can collide.
|
||||
activeProfile: string;
|
||||
activeProfileId: string;
|
||||
profiles: Profile[];
|
||||
loaded: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
switchProfile: (id: string) => Promise<void>;
|
||||
addProfile: (name: string) => Promise<string>;
|
||||
removeProfile: (id: string) => Promise<void>;
|
||||
renameProfile: (id: string, newName: string) => Promise<void>;
|
||||
logoutProfile: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const ProfileContext = createContext<ProfileContextValue | null>(null);
|
||||
|
||||
export const useProfile = () => {
|
||||
const ctx = useContext(ProfileContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useProfile must be used inside ProfileProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [activeProfile, setActiveProfile] = useState("");
|
||||
const [activeProfileId, setActiveProfileId] = useState("");
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (retryRef.current) {
|
||||
clearTimeout(retryRef.current);
|
||||
retryRef.current = null;
|
||||
}
|
||||
try {
|
||||
const u = await ProfilesSvc.Username();
|
||||
const [active, list] = await Promise.all([
|
||||
ProfilesSvc.GetActive(),
|
||||
ProfilesSvc.List(u),
|
||||
]);
|
||||
setUsername(u);
|
||||
setActiveProfile(active.profileName || "default");
|
||||
setActiveProfileId(active.id || "default");
|
||||
setProfiles(list);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("code = Unavailable")) {
|
||||
retryRef.current = setTimeout(() => {
|
||||
void refresh();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
setLoaded(true);
|
||||
await errorDialog({
|
||||
Title: i18next.t("profile.error.loadTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err));
|
||||
return () => {
|
||||
if (retryRef.current) clearTimeout(retryRef.current);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const off = Events.On(EVENT_PROFILE_CHANGED, () => {
|
||||
refresh().catch((err: unknown) =>
|
||||
console.error("[ProfileContext] refresh failed", err),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// id is a handle: the daemon resolves an exact ID, ID prefix, or unique
|
||||
// display name. The UI passes the profile's ID for precision.
|
||||
const switchProfile = useCallback(
|
||||
async (id: string) => {
|
||||
await ProfileSwitcher.SwitchActive({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// addProfile creates a profile by display name and returns the
|
||||
// daemon-generated ID, so the caller can immediately address it by ID.
|
||||
const addProfile = useCallback(
|
||||
async (name: string) => {
|
||||
const id = await ProfilesSvc.Add({ profileName: name, username });
|
||||
await refresh();
|
||||
return id;
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
const removeProfile = useCallback(
|
||||
async (id: string) => {
|
||||
await ProfilesSvc.Remove({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// The daemon resolves the handle (exact ID, ID prefix, or unique display
|
||||
// name) — passing the ID is precise and avoids collisions on rename.
|
||||
const renameProfile = useCallback(
|
||||
async (id: string, newName: string) => {
|
||||
await ProfilesSvc.Rename({ handle: id, newName, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
const logoutProfile = useCallback(
|
||||
async (id: string) => {
|
||||
await Connection.Logout({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
const value = useMemo<ProfileContextValue>(
|
||||
() => ({
|
||||
username,
|
||||
activeProfile,
|
||||
activeProfileId,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
logoutProfile,
|
||||
}),
|
||||
[
|
||||
username,
|
||||
activeProfile,
|
||||
activeProfileId,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
logoutProfile,
|
||||
],
|
||||
);
|
||||
|
||||
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
|
||||
};
|
||||
65
client/ui/frontend/src/contexts/RestrictionsContext.tsx
Normal file
65
client/ui/frontend/src/contexts/RestrictionsContext.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import { Restrictions } from "@bindings/services/models.js";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
const EVENT_SYSTEM = "netbird:event";
|
||||
const EMPTY = new Restrictions();
|
||||
|
||||
const RestrictionsContext = createContext<Restrictions>(EMPTY);
|
||||
|
||||
export const useRestrictions = () => useContext(RestrictionsContext);
|
||||
|
||||
export const RestrictionsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [restrictions, setRestrictions] = useState<Restrictions>(EMPTY);
|
||||
const mounted = useRef(true);
|
||||
const { status } = useStatus();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await SettingsSvc.GetRestrictions();
|
||||
if (mounted.current) setRestrictions(r);
|
||||
} catch (e) {
|
||||
console.error("[RestrictionsContext] refresh failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
|
||||
const off = Events.On(
|
||||
EVENT_SYSTEM,
|
||||
(e: { data?: { metadata?: { [k: string]: string | undefined } } }) => {
|
||||
if (e.data?.metadata?.type === "config_changed") refresh();
|
||||
},
|
||||
);
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") refresh();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
off();
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.status) refresh();
|
||||
}, [status?.status, refresh]);
|
||||
|
||||
return (
|
||||
<RestrictionsContext.Provider value={restrictions}>{children}</RestrictionsContext.Provider>
|
||||
);
|
||||
};
|
||||
267
client/ui/frontend/src/contexts/SettingsContext.tsx
Normal file
267
client/ui/frontend/src/contexts/SettingsContext.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services";
|
||||
import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
|
||||
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err);
|
||||
|
||||
export type AutostartState = { supported: boolean; enabled: boolean };
|
||||
|
||||
type SettingsContextValue = {
|
||||
config: Config;
|
||||
guiVersion: string;
|
||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
|
||||
saveNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
type AutostartContextValue = {
|
||||
autostart: AutostartState | null;
|
||||
setAutostartEnabled: (enabled: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
const SettingsContext = createContext<SettingsContextValue | null>(null);
|
||||
const AutostartContext = createContext<AutostartContextValue | null>(null);
|
||||
|
||||
export const useSettings = () => {
|
||||
const ctx = useContext(SettingsContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useSettings must be used inside SettingsProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const useAutostartSetting = () => {
|
||||
const ctx = useContext(AutostartContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
type LoadedConfig = { profileName: string; data: Config };
|
||||
|
||||
const useSettingsState = () => {
|
||||
const { username, activeProfileId, loaded: profileLoaded } = useProfile();
|
||||
const [loaded, setLoaded] = useState<LoadedConfig | null>(null);
|
||||
const [guiVersion, setGuiVersion] = useState<string>("—");
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadedRef = useRef<LoadedConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadedRef.current = loaded;
|
||||
}, [loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileLoaded || !activeProfileId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const load = async (showError: boolean) => {
|
||||
try {
|
||||
const data = await SettingsSvc.GetConfig({
|
||||
profileName: activeProfileId,
|
||||
username,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (saveTimer.current) return;
|
||||
setLoaded({ profileName: activeProfileId, data });
|
||||
} catch (e) {
|
||||
if (cancelled || !showError) return;
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.loadTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
load(true);
|
||||
|
||||
const off = Events.On(
|
||||
"netbird:event",
|
||||
(e: { data?: { metadata?: { [k: string]: string | undefined } } }) => {
|
||||
if (e.data?.metadata?.type === "config_changed") load(false);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
off();
|
||||
};
|
||||
}, [profileLoaded, activeProfileId, username]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Version.GUI().then((v) => {
|
||||
if (!cancelled) setGuiVersion(v);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (profileName: string, next: Config, preSharedKey?: string) => {
|
||||
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
...next,
|
||||
...preSharedKeyWrite,
|
||||
profileName,
|
||||
username,
|
||||
});
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[username],
|
||||
);
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof Config>(k: K, v: Config[K]) => {
|
||||
const cur = loadedRef.current;
|
||||
if (!cur) return;
|
||||
const next: LoadedConfig = {
|
||||
profileName: cur.profileName,
|
||||
data: { ...cur.data, [k]: v },
|
||||
};
|
||||
loadedRef.current = next;
|
||||
setLoaded(next);
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
save(next.profileName, next.data).catch(logSaveError);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const saveNow = useCallback(async () => {
|
||||
if (!loaded) return;
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
await save(loaded.profileName, loaded.data);
|
||||
}, [loaded, save]);
|
||||
|
||||
const saveField = useCallback(
|
||||
async <K extends keyof Config>(k: K, v: Config[K]) => {
|
||||
if (!loaded) return;
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
const next = { ...loaded.data, [k]: v };
|
||||
setLoaded({ profileName: loaded.profileName, data: next });
|
||||
await save(loaded.profileName, next);
|
||||
},
|
||||
[loaded, save],
|
||||
);
|
||||
|
||||
const saveFields = useCallback(
|
||||
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
||||
if (!loaded) return;
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
|
||||
const merged: Config = { ...loaded.data, ...partial };
|
||||
const next: Config =
|
||||
opts?.preSharedKey === undefined
|
||||
? merged
|
||||
: { ...merged, preSharedKeySet: opts.preSharedKey !== "" };
|
||||
setLoaded({ profileName: loaded.profileName, data: next });
|
||||
await save(loaded.profileName, next, opts?.preSharedKey);
|
||||
},
|
||||
[loaded, save],
|
||||
);
|
||||
|
||||
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
|
||||
|
||||
const value = useMemo<SettingsContextValue | null>(
|
||||
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
|
||||
[config, guiVersion, setField, saveField, saveFields, saveNow],
|
||||
);
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<div className={"min-h-0 flex-1 overflow-y-auto px-7 py-8"}>
|
||||
<SettingsSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
};
|
||||
|
||||
export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [autostart, setAutostart] = useState<AutostartState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const supported = await Autostart.Supported();
|
||||
const enabled = supported ? await Autostart.IsEnabled() : false;
|
||||
if (cancelled) return;
|
||||
setAutostart({ supported, enabled });
|
||||
})().catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
console.warn("[SettingsContext] load autostart state failed", err);
|
||||
setAutostart({ supported: false, enabled: false });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setAutostartEnabled = useCallback(async (enabled: boolean) => {
|
||||
setAutostart((s) => (s ? { ...s, enabled } : s));
|
||||
try {
|
||||
await Autostart.SetEnabled(enabled);
|
||||
} catch (e) {
|
||||
setAutostart((s) => (s ? { ...s, enabled: !enabled } : s));
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.general.autostart.errorTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AutostartContextValue>(
|
||||
() => ({ autostart, setAutostartEnabled }),
|
||||
[autostart, setAutostartEnabled],
|
||||
);
|
||||
|
||||
return <AutostartContext.Provider value={value}>{children}</AutostartContext.Provider>;
|
||||
};
|
||||
106
client/ui/frontend/src/contexts/StatusContext.tsx
Normal file
106
client/ui/frontend/src/contexts/StatusContext.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { DaemonFeed } from "@bindings/services";
|
||||
import { Status } from "@bindings/services/models.js";
|
||||
import { DaemonOutdatedOverlay } from "@/components/empty-state/DaemonOutdatedOverlay.tsx";
|
||||
import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
|
||||
import { isDaemonCompatible } from "@/lib/compat";
|
||||
|
||||
const EVENT_STATUS = "netbird:status";
|
||||
|
||||
type StatusContextValue = {
|
||||
status: Status | null;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
isReady: boolean;
|
||||
isDaemonUnavailable: boolean;
|
||||
isDaemonAvailable: boolean;
|
||||
isDaemonOutdated: boolean;
|
||||
};
|
||||
|
||||
const StatusContext = createContext<StatusContextValue | null>(null);
|
||||
|
||||
export const useStatus = () => {
|
||||
const ctx = useContext(StatusContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useStatus must be used inside StatusProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const StatusProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDaemonOutdated, setIsDaemonOutdated] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const s = await DaemonFeed.Get();
|
||||
setStatus(s);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
// Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise).
|
||||
setStatus(Status.createFrom({ status: "DaemonUnavailable" }));
|
||||
setError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err));
|
||||
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
|
||||
setStatus(ev.data);
|
||||
setError(null);
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const isReady = status !== null;
|
||||
const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable";
|
||||
const isDaemonAvailable = isReady && !isDaemonUnavailable;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDaemonAvailable) return;
|
||||
let cancelled = false;
|
||||
isDaemonCompatible()
|
||||
.then((ok) => {
|
||||
if (!cancelled) setIsDaemonOutdated(!ok);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[StatusContext] daemon compatible error", err);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isDaemonAvailable]);
|
||||
|
||||
const value = useMemo<StatusContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
error,
|
||||
refresh,
|
||||
isReady,
|
||||
isDaemonUnavailable,
|
||||
isDaemonAvailable,
|
||||
isDaemonOutdated,
|
||||
}),
|
||||
[status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable, isDaemonOutdated],
|
||||
);
|
||||
|
||||
return (
|
||||
<StatusContext.Provider value={value}>
|
||||
{isDaemonAvailable && !isDaemonOutdated && children}
|
||||
<DaemonUnavailableOverlay />
|
||||
<DaemonOutdatedOverlay />
|
||||
</StatusContext.Provider>
|
||||
);
|
||||
};
|
||||
89
client/ui/frontend/src/contexts/ViewModeContext.tsx
Normal file
89
client/ui/frontend/src/contexts/ViewModeContext.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Window } from "@wailsio/runtime";
|
||||
import { Preferences } from "@bindings/services";
|
||||
import { ViewMode as ViewModePref } from "@bindings/preferences/models.js";
|
||||
|
||||
export type ViewMode = "default" | "advanced";
|
||||
|
||||
// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px
|
||||
// title bar) while creation is content, so re-asserting a constant chops the content on first switch.
|
||||
export const VIEW_WIDTH: Record<ViewMode, number> = {
|
||||
default: 380,
|
||||
advanced: 900,
|
||||
};
|
||||
|
||||
type ViewModeContextValue = {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
};
|
||||
|
||||
const ViewModeContext = createContext<ViewModeContextValue | null>(null);
|
||||
|
||||
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [mode, setMode] = useState<ViewMode>("default");
|
||||
const modeRef = useRef<ViewMode>("default");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Preferences.Get()
|
||||
.then((prefs) => {
|
||||
if (cancelled) return;
|
||||
const saved = prefs?.viewMode as ViewMode | undefined;
|
||||
if (saved === "default" || saved === "advanced") {
|
||||
modeRef.current = saved;
|
||||
setMode(saved);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) =>
|
||||
console.warn("[ViewModeContext] load preferences failed", err),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Resize before flipping React state, else the layout paints into a window that hasn't grown yet.
|
||||
const setViewMode = useCallback((mode: ViewMode) => {
|
||||
if (modeRef.current === mode) return;
|
||||
modeRef.current = mode;
|
||||
(async () => {
|
||||
const size = await Window.Size().catch((err: unknown) => {
|
||||
console.warn("[ViewModeContext] read window size failed", err);
|
||||
return null;
|
||||
});
|
||||
const width = VIEW_WIDTH[mode];
|
||||
const height = size?.height ?? 640;
|
||||
await Window.SetSize(width, height).catch((err: unknown) =>
|
||||
console.warn("[ViewModeContext] set window size failed", err),
|
||||
);
|
||||
setMode(mode);
|
||||
const pref =
|
||||
mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault;
|
||||
Preferences.SetViewMode(pref).catch((err: unknown) =>
|
||||
console.error("[ViewModeContext] SetViewMode failed", err),
|
||||
);
|
||||
})().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err));
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ViewModeContextValue>(
|
||||
() => ({ viewMode: mode, setViewMode }),
|
||||
[mode, setViewMode],
|
||||
);
|
||||
|
||||
return <ViewModeContext.Provider value={value}>{children}</ViewModeContext.Provider>;
|
||||
};
|
||||
|
||||
export const useViewMode = () => {
|
||||
const ctx = useContext(ViewModeContext);
|
||||
if (!ctx) throw new Error("useViewMode must be used inside ViewModeProvider");
|
||||
return ctx;
|
||||
};
|
||||
45
client/ui/frontend/src/globals.css
Normal file
45
client/ui/frontend/src/globals.css
Normal file
@@ -0,0 +1,45 @@
|
||||
@font-face {
|
||||
font-family: "Inter Variable";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
src: url("./assets/fonts/inter-variable.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono Variable";
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* Body bg is fully opaque on purpose. The main window uses
|
||||
* MacBackdropTranslucent (main.go) and TitleBarHiddenInset, which on macOS
|
||||
* lets the desktop wallpaper bleed through any non-opaque pixel. A 90%
|
||||
* body alpha meant two machines with different wallpapers saw different
|
||||
* effective backgrounds. Matching Wails' BackgroundColour (#181A1D / nb-gray
|
||||
* DEFAULT) here keeps things consistent regardless of the OS backdrop.
|
||||
*/
|
||||
body {
|
||||
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
|
||||
}
|
||||
|
||||
.wails-draggable {
|
||||
--wails-draggable: drag;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wails-no-draggable {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
60
client/ui/frontend/src/hooks/useAutoSizeWindow.ts
Normal file
60
client/ui/frontend/src/hooks/useAutoSizeWindow.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { Window } from "@wailsio/runtime";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { isLinux } from "@/lib/platform";
|
||||
|
||||
// Sizes the current Wails window to the measured content height (keeping `width`),
|
||||
// then shows it. Re-applies on content resize and language change.
|
||||
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
|
||||
const ref = useRef<T | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let shown = false;
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
const showOnce = () => {
|
||||
if (shown) return;
|
||||
shown = true;
|
||||
Window.Show().catch(() => {});
|
||||
Window.Focus().catch(() => {});
|
||||
};
|
||||
const apply = async () => {
|
||||
if (!ready) return;
|
||||
const h = Math.ceil(el.getBoundingClientRect().height);
|
||||
if (h <= 0) return;
|
||||
try {
|
||||
// Window.SetSize takes the frame size, so add the OS title-bar height or content clips.
|
||||
const frame = await Window.Size();
|
||||
const targetH = h + Math.max(0, frame.height - window.innerHeight);
|
||||
// Linux: SetSize no-ops on a mapped non-resizable window (X11), so pin via min/max instead.
|
||||
if (isLinux()) {
|
||||
await Window.SetMinSize(width, targetH);
|
||||
await Window.SetMaxSize(width, targetH);
|
||||
}
|
||||
await Window.SetSize(width, targetH);
|
||||
showOnce();
|
||||
} catch {
|
||||
// window gone / not ready — ignore
|
||||
}
|
||||
};
|
||||
const scheduleApply = () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(apply);
|
||||
});
|
||||
};
|
||||
apply();
|
||||
const ro = new ResizeObserver(apply);
|
||||
ro.observe(el);
|
||||
i18next.on("languageChanged", scheduleApply);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
i18next.off("languageChanged", scheduleApply);
|
||||
};
|
||||
}, [width, ready]);
|
||||
return ref;
|
||||
}
|
||||
49
client/ui/frontend/src/hooks/useFocusVisible.ts
Normal file
49
client/ui/frontend/src/hooks/useFocusVisible.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Tracks the user's current input modality (keyboard vs pointer) at module
|
||||
// scope, mirroring what @react-aria/interactions does. Radix programmatically
|
||||
// focuses elements like Tabs triggers and Select triggers, which makes the
|
||||
// browser's :focus-visible heuristic light up on mouse-driven interactions too.
|
||||
// Gating focus styles on this hook lets us only paint a focus ring when the
|
||||
// user is actually navigating with the keyboard.
|
||||
// See react-aria's useFocusVisible for context.
|
||||
|
||||
type Modality = "keyboard" | "pointer";
|
||||
|
||||
let currentModality: Modality = "pointer";
|
||||
const subscribers = new Set<(m: Modality) => void>();
|
||||
|
||||
const setModality = (m: Modality) => {
|
||||
if (m === currentModality) return;
|
||||
currentModality = m;
|
||||
subscribers.forEach((cb) => cb(m));
|
||||
};
|
||||
|
||||
const isKeyboardEvent = (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return false;
|
||||
return e.key === "Tab" || e.key === "Escape" || e.key.startsWith("Arrow");
|
||||
};
|
||||
|
||||
if (globalThis.window !== undefined) {
|
||||
globalThis.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (isKeyboardEvent(e)) setModality("keyboard");
|
||||
},
|
||||
true,
|
||||
);
|
||||
globalThis.addEventListener("pointerdown", () => setModality("pointer"), true);
|
||||
}
|
||||
|
||||
export const useFocusVisible = (): boolean => {
|
||||
const [visible, setVisible] = useState(currentModality === "keyboard");
|
||||
useEffect(() => {
|
||||
setVisible(currentModality === "keyboard");
|
||||
const cb = (m: Modality) => setVisible(m === "keyboard");
|
||||
subscribers.add(cb);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
return visible;
|
||||
};
|
||||
46
client/ui/frontend/src/hooks/useKeyboardShortcut.ts
Normal file
46
client/ui/frontend/src/hooks/useKeyboardShortcut.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from "react";
|
||||
import { isMacOS } from "@/lib/platform";
|
||||
|
||||
export type Shortcut = {
|
||||
key: string;
|
||||
cmd?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
preventDefault?: boolean;
|
||||
};
|
||||
|
||||
export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) return;
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (!!shortcut.cmd !== mod) return;
|
||||
if (!!shortcut.shift !== e.shiftKey) return;
|
||||
if (!!shortcut.alt !== e.altKey) return;
|
||||
if (shortcut.preventDefault !== false) e.preventDefault();
|
||||
callback();
|
||||
};
|
||||
globalThis.addEventListener("keydown", onKey);
|
||||
return () => globalThis.removeEventListener("keydown", onKey);
|
||||
}, [
|
||||
shortcut.key,
|
||||
shortcut.cmd,
|
||||
shortcut.shift,
|
||||
shortcut.alt,
|
||||
shortcut.preventDefault,
|
||||
callback,
|
||||
enabled,
|
||||
]);
|
||||
};
|
||||
|
||||
export const formatShortcut = (shortcut: Shortcut): string => {
|
||||
// navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac.
|
||||
const mac = isMacOS();
|
||||
const parts: string[] = [];
|
||||
if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl");
|
||||
if (shortcut.shift) parts.push(mac ? "⇧" : "Shift");
|
||||
if (shortcut.alt) parts.push(mac ? "⌥" : "Alt");
|
||||
parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key);
|
||||
return parts.join(mac ? "" : "+");
|
||||
};
|
||||
143
client/ui/frontend/src/hooks/useManagementUrl.ts
Normal file
143
client/ui/frontend/src/hooks/useManagementUrl.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { useConfirm } from "@/contexts/DialogContext.tsx";
|
||||
|
||||
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
|
||||
const CLOUD_MANAGEMENT_URLS = new Set([
|
||||
CLOUD_MANAGEMENT_URL,
|
||||
"https://api.wiretrustee.com:443", // legacy cloud endpoint
|
||||
]);
|
||||
|
||||
export function isNetbirdCloud(url: string): boolean {
|
||||
if (!url || url.trim() === "") return true;
|
||||
return CLOUD_MANAGEMENT_URLS.has(url);
|
||||
}
|
||||
|
||||
// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4.
|
||||
// Syntactic validation only — reachability is checked via checkManagementUrlReachable.
|
||||
export const URL_PATTERN = new RegExp(
|
||||
String.raw`^(https?:\/\/)?` +
|
||||
String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` +
|
||||
String.raw`((\d{1,3}\.){3}\d{1,3}))` +
|
||||
String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` +
|
||||
String.raw`(\?[;&a-z\d%_.~+=-]*)?` +
|
||||
String.raw`(\#[-a-z\d_]*)?$`,
|
||||
"i",
|
||||
);
|
||||
|
||||
export function normalizeManagementUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
export function isValidManagementUrl(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
return URL_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat as a soft warning, not a hard block.
|
||||
export async function checkManagementUrlReachable(
|
||||
url: string,
|
||||
timeoutMs: number = 5000,
|
||||
): Promise<boolean> {
|
||||
const target = normalizeManagementUrl(url);
|
||||
if (!target) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export enum ManagementMode {
|
||||
Cloud = "cloud",
|
||||
SelfHosted = "selfhosted",
|
||||
}
|
||||
|
||||
function modeFromUrl(url: string): ManagementMode {
|
||||
return isNetbirdCloud(url) ? ManagementMode.Cloud : ManagementMode.SelfHosted;
|
||||
}
|
||||
|
||||
export function useManagementUrl() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { config, saveField } = useSettings();
|
||||
const [modeState, setModeState] = useState<ManagementMode>(modeFromUrl(config.managementUrl));
|
||||
const [url, setUrl] = useState(
|
||||
isNetbirdCloud(config.managementUrl) ? "" : config.managementUrl,
|
||||
);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setModeState(modeFromUrl(config.managementUrl));
|
||||
if (!isNetbirdCloud(config.managementUrl)) {
|
||||
setUrl(config.managementUrl);
|
||||
}
|
||||
}, [config.managementUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnreachable(false);
|
||||
}, [url, modeState]);
|
||||
|
||||
const setMode = async (next: ManagementMode) => {
|
||||
if (next === ManagementMode.Cloud && !isNetbirdCloud(config.managementUrl)) {
|
||||
const ok = await confirm({
|
||||
title: t("settings.general.management.switchCloudTitle"),
|
||||
description: t("settings.general.management.switchCloudMessage"),
|
||||
confirmLabel: t("settings.general.management.switchCloudConfirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setModeState(ManagementMode.Cloud);
|
||||
saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) =>
|
||||
console.error("save managementUrl failed", err),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setModeState(next);
|
||||
};
|
||||
|
||||
const normalizedUrl = normalizeManagementUrl(url);
|
||||
const urlValid = isValidManagementUrl(url);
|
||||
const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
|
||||
const dirty = targetUrl !== config.managementUrl;
|
||||
const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
|
||||
const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid);
|
||||
const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
|
||||
|
||||
const save = async () => {
|
||||
if (modeState === ManagementMode.SelfHosted && !unreachable) {
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(targetUrl);
|
||||
setChecking(false);
|
||||
if (!reachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveField("managementUrl", targetUrl);
|
||||
setUnreachable(false);
|
||||
};
|
||||
|
||||
return {
|
||||
mode: modeState,
|
||||
setMode,
|
||||
url,
|
||||
setUrl,
|
||||
displayUrl,
|
||||
showError,
|
||||
canSave,
|
||||
save,
|
||||
checking,
|
||||
unreachable,
|
||||
};
|
||||
}
|
||||
27
client/ui/frontend/src/layouts/AppLayout.tsx
Normal file
27
client/ui/frontend/src/layouts/AppLayout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx";
|
||||
import { StatusProvider } from "@/contexts/StatusContext.tsx";
|
||||
import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
|
||||
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
|
||||
import { DialogProvider } from "@/contexts/DialogContext.tsx";
|
||||
import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx";
|
||||
|
||||
export const AppLayout = () => {
|
||||
return (
|
||||
<div className={"relative flex h-full flex-col"}>
|
||||
<DialogProvider>
|
||||
<StatusProvider>
|
||||
<ProfileProvider>
|
||||
<RestrictionsProvider>
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<Outlet />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</RestrictionsProvider>
|
||||
</ProfileProvider>
|
||||
</StatusProvider>
|
||||
</DialogProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
client/ui/frontend/src/layouts/AppRightPanel.tsx
Normal file
38
client/ui/frontend/src/layouts/AppRightPanel.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
overlayOpen?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PANEL_TRANSITION = {
|
||||
duration: 0.32,
|
||||
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
|
||||
};
|
||||
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable relative m-5",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-br-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ x: overlayOpen ? -48 : 0 }}
|
||||
transition={PANEL_TRANSITION}
|
||||
className={"flex min-h-0 min-w-0 flex-1 flex-col"}
|
||||
style={{ pointerEvents: overlayOpen ? "none" : "auto" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
{overlay}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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));
|
||||
}
|
||||
19
client/ui/frontend/src/lib/compat.ts
Normal file
19
client/ui/frontend/src/lib/compat.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Compat } from "@bindings/services";
|
||||
|
||||
let cached: boolean | null = null;
|
||||
|
||||
/**
|
||||
* isDaemonCompatible probes whether the running daemon implements the WailsUIReady
|
||||
* RPC. A false result means the daemon predates this UI (Unimplemented) and is too
|
||||
* old to drive it. The Go side returns an error instead when the daemon is simply
|
||||
* unreachable, so a throw here is NOT an outdated daemon — treat it as "unknown"
|
||||
* and let the normal connection flow report it.
|
||||
*
|
||||
* The result is cached for the session: daemon identity does not change without a
|
||||
* UI restart, and a freshly started daemon is reachable again under the same socket.
|
||||
*/
|
||||
export async function isDaemonCompatible(): Promise<boolean> {
|
||||
if (cached !== null) return cached;
|
||||
cached = await Compat.DaemonReady();
|
||||
return cached;
|
||||
}
|
||||
121
client/ui/frontend/src/lib/connection.ts
Normal file
121
client/ui/frontend/src/lib/connection.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
|
||||
export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
export const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
|
||||
let connectionInFlight = false;
|
||||
|
||||
type SsoState = {
|
||||
cancelled: boolean;
|
||||
offCancel?: () => void;
|
||||
offSignal?: () => void;
|
||||
};
|
||||
|
||||
async function openBrowserLoginUri(uri: string): Promise<void> {
|
||||
try {
|
||||
await WindowManager.OpenBrowserLogin(uri);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
state.offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
|
||||
state.cancelled = true;
|
||||
resolve();
|
||||
});
|
||||
if (!signal) return;
|
||||
const onAbort = () => {
|
||||
state.cancelled = true;
|
||||
resolve();
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", onAbort);
|
||||
state.offSignal = () => signal.removeEventListener("abort", onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSsoLogin(
|
||||
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
|
||||
state: SsoState,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const uri = result.verificationUriComplete || result.verificationUri;
|
||||
if (uri) await openBrowserLoginUri(uri);
|
||||
|
||||
const cancelPromise = buildSsoCancelPromise(state, signal);
|
||||
const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" });
|
||||
|
||||
try {
|
||||
await Promise.race([waitPromise, cancelPromise]);
|
||||
} finally {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
}
|
||||
|
||||
if (state.cancelled) {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise<void> {
|
||||
if (connectionInFlight || signal?.aborted) {
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
connectionInFlight = true;
|
||||
|
||||
const state: SsoState = { cancelled: false };
|
||||
let connectError: unknown;
|
||||
|
||||
try {
|
||||
const result = await Connection.Login({
|
||||
profileName: "",
|
||||
username: "",
|
||||
managementUrl: "",
|
||||
setupKey: "",
|
||||
preSharedKey: "",
|
||||
hostname: "",
|
||||
hint: "",
|
||||
});
|
||||
|
||||
if (signal?.aborted) state.cancelled = true;
|
||||
|
||||
if (!state.cancelled && result.needsSsoLogin) {
|
||||
await runSsoLogin(result, state, signal);
|
||||
}
|
||||
|
||||
if (!state.cancelled && signal?.aborted) state.cancelled = true;
|
||||
|
||||
if (!state.cancelled) {
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
}
|
||||
} catch (e) {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
if (!state.cancelled) connectError = e;
|
||||
} finally {
|
||||
state.offCancel?.();
|
||||
state.offSignal?.();
|
||||
connectionInFlight = false;
|
||||
onSettled?.();
|
||||
}
|
||||
|
||||
if (connectError !== undefined) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(connectError),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.cancelled && signal) {
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
}
|
||||
}
|
||||
60
client/ui/frontend/src/lib/errors.ts
Normal file
60
client/ui/frontend/src/lib/errors.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
type ClassifiedError = { short: string; long: string };
|
||||
|
||||
const asObject = (v: unknown): Record<string, unknown> | null =>
|
||||
v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||
|
||||
const parseJsonObject = (s: unknown): Record<string, unknown> | null => {
|
||||
if (typeof s !== "string") return null;
|
||||
const t = s.trim();
|
||||
if (!t.startsWith("{") || !t.endsWith("}")) return null;
|
||||
try {
|
||||
return asObject(JSON.parse(t));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const toWailsEnvelope = (e: unknown): Record<string, unknown> | null => {
|
||||
const obj = asObject(e);
|
||||
if (!obj) return null;
|
||||
return asObject(obj.cause) ?? parseJsonObject(obj.message);
|
||||
};
|
||||
|
||||
// Read { short, long } from wherever the classified error sits in the envelope
|
||||
const toClassifiedError = (v: unknown): ClassifiedError | null => {
|
||||
const o = asObject(v);
|
||||
if (!o) return null;
|
||||
const short = typeof o.short === "string" ? o.short : "";
|
||||
const long = typeof o.long === "string" ? o.long : "";
|
||||
return short || long ? { short, long } : null;
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
const envelope = toWailsEnvelope(e);
|
||||
|
||||
// Prefer the structured { short, long } the daemon classifier produced.
|
||||
const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
if (classified) {
|
||||
const { short, long } = classified;
|
||||
if (short && long && long !== short) return `${short} Details: ${long}`;
|
||||
if (short) return short;
|
||||
if (long) return long;
|
||||
}
|
||||
|
||||
// Unclassified (a service returned the raw daemon error)
|
||||
const message = envelope?.message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
};
|
||||
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
}
|
||||
44
client/ui/frontend/src/lib/formatters.ts
Normal file
44
client/ui/frontend/src/lib/formatters.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export const formatBytes = (bytes: number, decimals: number = 2): string => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
|
||||
|
||||
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
export const latencyColor = (ms: number): string => {
|
||||
if (ms <= 0) return "text-nb-gray-400";
|
||||
if (ms < 100) return "text-green-400";
|
||||
return "text-yellow-400";
|
||||
};
|
||||
|
||||
export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => {
|
||||
if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null;
|
||||
const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds));
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix.
|
||||
export const shortenDns = (fqdn: string | undefined | null): string => {
|
||||
if (!fqdn) return "";
|
||||
const dot = fqdn.indexOf(".");
|
||||
return dot === -1 ? fqdn : fqdn.slice(0, dot);
|
||||
};
|
||||
|
||||
// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows.
|
||||
export const formatRemaining = (seconds: number): string => {
|
||||
const s = Math.max(0, Math.trunc(seconds));
|
||||
const days = Math.floor(s / 86400);
|
||||
const hours = Math.floor((s % 86400) / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
const secs = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
return `${pad(minutes)}:${pad(secs)}`;
|
||||
};
|
||||
103
client/ui/frontend/src/lib/i18n.ts
Normal file
103
client/ui/frontend/src/lib/i18n.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import i18next from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
|
||||
import { Preferences, I18n } from "@bindings/services";
|
||||
import { type LanguageCode } from "@bindings/i18n/models.js";
|
||||
|
||||
// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups.
|
||||
type BundleEntry = { message: string; description?: string };
|
||||
const bundleModules = import.meta.glob<Record<string, BundleEntry>>(
|
||||
"../../../i18n/locales/*/common.json",
|
||||
{ eager: true, import: "default" },
|
||||
);
|
||||
|
||||
const resources: Record<string, { common: Record<string, string> }> = {};
|
||||
for (const path in bundleModules) {
|
||||
const match = /locales\/([^/]+)\/common\.json$/.exec(path);
|
||||
if (match) {
|
||||
const entries = bundleModules[path];
|
||||
const messages: Record<string, string> = {};
|
||||
for (const key in entries) {
|
||||
messages[key] = entries[key].message;
|
||||
}
|
||||
resources[match[1]] = { common: messages };
|
||||
}
|
||||
}
|
||||
|
||||
function detectBrowserLanguage(available: string[]): string | null {
|
||||
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
);
|
||||
const byLower = new Map(available.map((code) => [code.toLowerCase(), code]));
|
||||
for (const tag of tags) {
|
||||
const lower = tag.toLowerCase();
|
||||
const exact = byLower.get(lower);
|
||||
if (exact) return exact;
|
||||
const base = byLower.get(lower.split("-")[0]);
|
||||
if (base) return base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// An empty persisted language code is the Go-side signal for first run.
|
||||
export async function initI18n(): Promise<void> {
|
||||
const available = Object.keys(resources);
|
||||
let language = "en";
|
||||
let firstRun = false;
|
||||
try {
|
||||
const prefs = await Preferences.Get();
|
||||
if (prefs?.language) {
|
||||
language = prefs.language;
|
||||
} else {
|
||||
firstRun = true;
|
||||
language = detectBrowserLanguage(available) ?? "en";
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("read preferences for language failed, defaulting to en", e);
|
||||
}
|
||||
|
||||
if (firstRun) {
|
||||
Preferences.SetLanguage(language as LanguageCode).catch((err: unknown) =>
|
||||
console.warn("persist detected language failed", err),
|
||||
);
|
||||
}
|
||||
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: language,
|
||||
fallbackLng: "en",
|
||||
defaultNS: "common",
|
||||
ns: ["common"],
|
||||
resources,
|
||||
interpolation: {
|
||||
prefix: "{",
|
||||
suffix: "}",
|
||||
escapeValue: false,
|
||||
},
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
syncDocumentLang();
|
||||
i18next.on("languageChanged", syncDocumentLang);
|
||||
|
||||
Events.On("netbird:preferences:changed", (e) => {
|
||||
const next = e.data?.language;
|
||||
if (next && next !== i18next.language) {
|
||||
i18next.changeLanguage(next).catch((err: unknown) => {
|
||||
console.error("changeLanguage failed", err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncDocumentLang() {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = i18next.language;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLanguages() {
|
||||
return I18n.Languages();
|
||||
}
|
||||
|
||||
export default i18next;
|
||||
139
client/ui/frontend/src/lib/logs.ts
Normal file
139
client/ui/frontend/src/lib/logs.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { UILog } from "@bindings/services";
|
||||
|
||||
type Level = "trace" | "debug" | "info" | "warn" | "error";
|
||||
|
||||
const METHOD_LEVELS: Record<string, Level> = {
|
||||
trace: "trace",
|
||||
debug: "debug",
|
||||
log: "info",
|
||||
info: "info",
|
||||
warn: "warn",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const IGNORED_SOURCES = new Set(["welcome.ts"]);
|
||||
|
||||
const RATE_LIMIT = 50;
|
||||
const RATE_WINDOW_MS = 1000;
|
||||
|
||||
let installed = false;
|
||||
let inForward = false;
|
||||
let windowStart = 0;
|
||||
let windowCount = 0;
|
||||
|
||||
function describeCause(rawCause: unknown): string {
|
||||
if (rawCause instanceof Error) return `${rawCause.name}: ${rawCause.message}`;
|
||||
if (typeof rawCause === "object" && rawCause !== null) {
|
||||
try {
|
||||
return JSON.stringify(rawCause);
|
||||
} catch {
|
||||
// Circular ref — fall through to a tag instead of "[object Object]".
|
||||
return `<${rawCause.constructor?.name ?? "object"}>`;
|
||||
}
|
||||
}
|
||||
return String(rawCause);
|
||||
}
|
||||
|
||||
function formatCause(rawCause: unknown): string {
|
||||
if (rawCause === undefined) return "";
|
||||
return `\ncaused by ${describeCause(rawCause)}`;
|
||||
}
|
||||
|
||||
// WebKit (macOS WKWebView) omits the "Name: message" header from Error.stack,
|
||||
// so a bare stack hides the real cause. Prepend name+message, then the stack.
|
||||
function formatError(e: Error): string {
|
||||
const head = `${e.name}: ${e.message}`;
|
||||
const cause = formatCause((e as { cause?: unknown }).cause);
|
||||
if (!e.stack) return `${head}${cause}`;
|
||||
if (e.stack.startsWith(head)) return `${head}${cause}`;
|
||||
return `${head}${cause}\n${e.stack}`;
|
||||
}
|
||||
|
||||
function format(args: unknown[]): string {
|
||||
return args
|
||||
.map((a) => {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return formatError(a);
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function parseStackLine(line: string): string {
|
||||
// Find the file:line:col tail at the end of the path.
|
||||
const colonCol = line.lastIndexOf(":");
|
||||
if (colonCol <= 0) return "";
|
||||
const colonLine = line.lastIndexOf(":", colonCol - 1);
|
||||
if (colonLine <= 0) return "";
|
||||
const col = line.slice(colonCol + 1);
|
||||
const lineNo = line.slice(colonLine + 1, colonCol);
|
||||
if (!/^\d+$/.test(col) || !/^\d+$/.test(lineNo)) return "";
|
||||
const before = line.slice(0, colonLine);
|
||||
const sep = Math.max(
|
||||
before.lastIndexOf("/"),
|
||||
before.lastIndexOf("\\"),
|
||||
before.lastIndexOf("("),
|
||||
before.lastIndexOf(" "),
|
||||
);
|
||||
const file = before.slice(sep + 1);
|
||||
if (!file.includes(".")) return "";
|
||||
return `${file}:${lineNo}`;
|
||||
}
|
||||
|
||||
function callerSource(): string {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) return "";
|
||||
for (const line of stack.split("\n").slice(1)) {
|
||||
if (line.includes("/logs.ts")) continue;
|
||||
const parsed = parseStackLine(line);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function forward(level: Level, args: unknown[]) {
|
||||
if (inForward) return;
|
||||
inForward = true;
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (now - windowStart >= RATE_WINDOW_MS) {
|
||||
windowStart = now;
|
||||
windowCount = 0;
|
||||
}
|
||||
if (++windowCount > RATE_LIMIT) return;
|
||||
|
||||
const source = callerSource();
|
||||
if (IGNORED_SOURCES.has(source.split(":")[0])) return;
|
||||
// Don't touch console here — it would recurse back into forward().
|
||||
UILog.Log(level, source, format(args)).catch(() => {});
|
||||
} catch {
|
||||
// Swallow — log forwarding must never throw back into the caller.
|
||||
} finally {
|
||||
inForward = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initLogForwarding() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
const c = console as unknown as Record<string, (...a: unknown[]) => void>;
|
||||
for (const [method, level] of Object.entries(METHOD_LEVELS)) {
|
||||
const original = c[method]?.bind(console);
|
||||
c[method] = (...args: unknown[]) => {
|
||||
original?.(...args);
|
||||
forward(level, args);
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.addEventListener("error", (e) => {
|
||||
forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]);
|
||||
});
|
||||
globalThis.addEventListener("unhandledrejection", (e) => {
|
||||
forward("error", ["unhandled promise rejection:", e.reason]);
|
||||
});
|
||||
}
|
||||
39
client/ui/frontend/src/lib/platform.ts
Normal file
39
client/ui/frontend/src/lib/platform.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { System } from "@wailsio/runtime";
|
||||
|
||||
export type Platform = {
|
||||
isWindows: boolean;
|
||||
isMacOS: boolean;
|
||||
};
|
||||
|
||||
let cached: Platform | null = null;
|
||||
|
||||
export async function initPlatform(): Promise<void> {
|
||||
if (cached) return;
|
||||
|
||||
const syncIsMac = System.IsMac();
|
||||
const syncIsWindows = System.IsWindows();
|
||||
|
||||
let env: Awaited<ReturnType<typeof System.Environment>> | null = null;
|
||||
try {
|
||||
env = await System.Environment();
|
||||
} catch (e) {
|
||||
console.error("[platform] System.Environment() threw:", e);
|
||||
}
|
||||
|
||||
const os = (env?.OS ?? "").toLowerCase();
|
||||
cached = {
|
||||
isWindows: os ? os === "windows" : syncIsWindows,
|
||||
isMacOS: os ? os === "darwin" : syncIsMac,
|
||||
};
|
||||
}
|
||||
|
||||
function get(): Platform {
|
||||
if (!cached) {
|
||||
throw new Error("platform: initPlatform() must complete before sync getters are used");
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export const isWindows = (): boolean => get().isWindows;
|
||||
export const isMacOS = (): boolean => get().isMacOS;
|
||||
export const isLinux = (): boolean => !get().isWindows && !get().isMacOS;
|
||||
27
client/ui/frontend/src/lib/sorting.ts
Normal file
27
client/ui/frontend/src/lib/sorting.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Stable, order-preserving reconciliation for lists that re-fetch from the daemon
|
||||
// on every status push (peers, networks, profiles). Re-sorting on each refresh would
|
||||
// make rows jump around under the user, so instead:
|
||||
// - items already on screen keep their existing order (from `prev`),
|
||||
// - items that vanished are dropped,
|
||||
// - newly-arrived items are sorted among themselves (`compareFresh`) and appended.
|
||||
// Net effect: the only visible movement is new rows landing at the bottom.
|
||||
//
|
||||
// Must stay pure and idempotent: callers write the returned `order` into a ref
|
||||
// during render (useMemo), so a rerun must reproduce the first pass — never
|
||||
// branch on run count or read external mutable state.
|
||||
export function reconcileOrder<T>(
|
||||
prev: string[],
|
||||
items: T[],
|
||||
keyOf: (item: T) => string,
|
||||
compareFresh: (a: T, b: T) => number,
|
||||
): { order: string[]; items: T[] } {
|
||||
const byKey = new Map(items.map((i) => [keyOf(i), i]));
|
||||
const kept = prev.filter((k) => byKey.has(k));
|
||||
const known = new Set(kept);
|
||||
const fresh = items
|
||||
.filter((i) => !known.has(keyOf(i)))
|
||||
.sort(compareFresh)
|
||||
.map(keyOf);
|
||||
const order = [...kept, ...fresh];
|
||||
return { order, items: order.map((k) => byKey.get(k)!) };
|
||||
}
|
||||
25
client/ui/frontend/src/lib/welcome.ts
Normal file
25
client/ui/frontend/src/lib/welcome.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const ART = `
|
||||
_ __ __ ____ _ __ ______ __ __ __
|
||||
/ | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / /
|
||||
/ |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ /
|
||||
/ /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ /
|
||||
/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/
|
||||
`;
|
||||
|
||||
export function welcome() {
|
||||
const message = `%c${ART}%c
|
||||
NetBird — The Only Secure Access Platform You'll Ever Need.
|
||||
|
||||
WEBSITE: https://netbird.io/
|
||||
WE'RE HIRING: https://netbird.io/careers
|
||||
OPEN SOURCE: https://github.com/netbirdio/netbird
|
||||
`;
|
||||
|
||||
// Intentional NetBird ASCII banner in the devtools console.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
message,
|
||||
"color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;",
|
||||
"color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;",
|
||||
);
|
||||
}
|
||||
27
client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx
Normal file
27
client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { forwardRef, type HTMLAttributes } from "react";
|
||||
import { ArrowUpCircleIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const UpdateBadge = forwardRef<HTMLDivElement, Props>(function UpdateBadge(
|
||||
{ size = 15, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("relative flex items-center justify-center", className)}
|
||||
{...rest}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"pointer-events-none absolute inline-flex h-[15px] w-[15px] animate-ping rounded-full bg-netbird opacity-20"
|
||||
}
|
||||
/>
|
||||
<ArrowUpCircleIcon size={size} className={"text-netbird"} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Loader2, XCircle } from "lucide-react";
|
||||
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
// Sustained gRPC failure during install is taken as success (installer restarts the daemon mid-flight).
|
||||
const DAEMON_DOWN_GRACE_MS = 5000;
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
type Phase =
|
||||
| { kind: "running" }
|
||||
| { kind: "timeout" }
|
||||
| { kind: "canceled" }
|
||||
| { kind: "failed"; message: string };
|
||||
|
||||
export default function UpdateInProgressDialog() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const version = params.get("version") ?? "";
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "running" });
|
||||
const phaseRef = useRef(phase);
|
||||
phaseRef.current = phase;
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let done = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const start = Date.now();
|
||||
let firstUnreachableAt: number | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled || done) return;
|
||||
if (phaseRef.current.kind !== "running") return;
|
||||
|
||||
if (Date.now() - start > TIMEOUT_MS) {
|
||||
done = true;
|
||||
setPhase({ kind: "timeout" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await UpdateSvc.GetInstallerResult();
|
||||
if (cancelled || done || phaseRef.current.kind !== "running") return;
|
||||
firstUnreachableAt = null;
|
||||
if (r.success) {
|
||||
done = true;
|
||||
UpdateSvc.Quit().catch(console.error);
|
||||
return;
|
||||
}
|
||||
if (r.errorMsg) {
|
||||
done = true;
|
||||
setPhase(mapInstallError(r.errorMsg));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (cancelled || done || phaseRef.current.kind !== "running") return;
|
||||
const now = Date.now();
|
||||
if (firstUnreachableAt === null) {
|
||||
firstUnreachableAt = now;
|
||||
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
|
||||
done = true;
|
||||
UpdateSvc.Quit().catch(console.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled && !done) {
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isError = phase.kind !== "running";
|
||||
const errorInfo = isError ? classifyPhase(phase, version, t) : null;
|
||||
const updatingHeading = version
|
||||
? t("update.overlay.updatingVersion", { version })
|
||||
: t("update.overlay.updating");
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
{isError ? (
|
||||
<SquareIcon icon={XCircle} className={"bg-red-500 [&_svg]:text-white"} />
|
||||
) : (
|
||||
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
|
||||
)}
|
||||
|
||||
<div className={"flex flex-col items-center gap-2"}>
|
||||
<DialogHeading className={"text-balance"}>
|
||||
{errorInfo ? errorInfo.title : updatingHeading}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{errorInfo ? (
|
||||
<>
|
||||
{errorInfo.description}
|
||||
{errorInfo.message && (
|
||||
<>
|
||||
<br />
|
||||
<span className={"first-letter:uppercase"}>
|
||||
{errorInfo.message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
t("update.overlay.description")
|
||||
)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"secondary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={() => WindowManager.CloseInstallProgress().catch(console.error)}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function mapInstallError(msg: string): Phase {
|
||||
const m = msg.trim().toLowerCase();
|
||||
if (m === "") return { kind: "failed", message: "" };
|
||||
if (m.includes("deadline exceeded") || m.includes("timeout") || m.includes("timed out")) {
|
||||
return { kind: "timeout" };
|
||||
}
|
||||
if (m.includes("canceled") || m.includes("cancelled") || m.includes("cancel")) {
|
||||
return { kind: "canceled" };
|
||||
}
|
||||
return { kind: "failed", message: msg };
|
||||
}
|
||||
|
||||
type Variant = { title: string; description: string; message?: string };
|
||||
|
||||
function classifyPhase(
|
||||
phase: Phase,
|
||||
version: string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): Variant {
|
||||
const target = version
|
||||
? t("update.overlay.error.targetVersion", { version })
|
||||
: t("update.overlay.error.targetFallback");
|
||||
switch (phase.kind) {
|
||||
case "timeout":
|
||||
return {
|
||||
title: t("update.overlay.error.timeoutTitle"),
|
||||
description: t("update.overlay.error.timeoutDescription", { target }),
|
||||
};
|
||||
case "canceled":
|
||||
return {
|
||||
title: t("update.overlay.error.canceledTitle"),
|
||||
description: t("update.overlay.error.canceledDescription", { target }),
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
title: t("update.overlay.error.failTitle"),
|
||||
description: t("update.overlay.error.failDescription", { target }),
|
||||
message: phase.message || t("update.overlay.error.unknownMessage"),
|
||||
};
|
||||
default:
|
||||
return { title: "", description: "" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { DownloadIcon, NotepadText } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useClientVersion } from "@/contexts/ClientVersionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => {
|
||||
window.open(url, "_blank");
|
||||
});
|
||||
}
|
||||
|
||||
export function UpdateVersionCard() {
|
||||
const { t } = useTranslation();
|
||||
const { updateVersion, enforced, triggerUpdate } = useClientVersion();
|
||||
|
||||
if (updateVersion) {
|
||||
const titleKey = enforced
|
||||
? "update.card.versionAvailableInstall"
|
||||
: "update.card.versionAvailableDownload";
|
||||
return (
|
||||
<Card className={"max-w-lg"}>
|
||||
<div>
|
||||
<Title>{t(titleKey, { version: updateVersion })}</Title>
|
||||
<Link
|
||||
url={`https://github.com/netbirdio/netbird/releases/tag/v${updateVersion}`}
|
||||
>
|
||||
{t("update.card.whatsNew")}
|
||||
</Link>
|
||||
</div>
|
||||
{enforced ? (
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
{t("update.card.installNow")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"xs"}
|
||||
onClick={() => openUrl(GITHUB_RELEASES)}
|
||||
>
|
||||
<DownloadIcon size={14} />
|
||||
{t("update.card.getInstaller")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={"max-w-lg"}>
|
||||
<div>
|
||||
<Title>{t("update.card.onLatestVersion")}</Title>
|
||||
<p className={"text-sm text-nb-gray-300"}>{t("update.card.autoCheckInterval")}</p>
|
||||
</div>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(GITHUB_RELEASES)}>
|
||||
<NotepadText size={14} />
|
||||
{t("update.card.changelog")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 rounded-md border border-nb-gray-800 bg-nb-gray-910 px-4 py-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Title({ children }: Readonly<{ children: ReactNode }>) {
|
||||
return <p className={"text-sm font-semibold"}>{children}</p>;
|
||||
}
|
||||
|
||||
function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) {
|
||||
return (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => openUrl(url)}
|
||||
className={
|
||||
"text-sm font-medium text-netbird hover:underline hover:decoration-[0.5px] hover:underline-offset-4"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
64
client/ui/frontend/src/modules/error/ErrorDialog.tsx
Normal file
64
client/ui/frontend/src/modules/error/ErrorDialog.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const WINDOW_WIDTH = 380;
|
||||
|
||||
export default function ErrorDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const title = params.get("title") || t("window.title.error");
|
||||
const message = params.get("message") || "";
|
||||
|
||||
const close = useCallback(() => {
|
||||
WindowManager.CloseError().catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
globalThis.addEventListener("keydown", onKey);
|
||||
return () => globalThis.removeEventListener("keydown", onKey);
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
|
||||
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading id={"nb-error-dialog-title"} className={"text-balance"}>
|
||||
{title}
|
||||
</DialogHeading>
|
||||
{message && (
|
||||
<DialogDescription className={"text-balance"}>
|
||||
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={close}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Connection } from "@bindings/services";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const EVENT_CANCEL = "browser-login:cancel";
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
export default function LoginWaitingForBrowserDialog() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const uri = params.get("uri") ?? "";
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const openedRef = useRef(false);
|
||||
|
||||
const reportOpenFailure = useCallback(
|
||||
(e: unknown) => {
|
||||
void errorDialog({
|
||||
Title: t("browserLogin.openFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Open the browser only after mount, or it lands on top of the still-hidden popup.
|
||||
useEffect(() => {
|
||||
if (!uri || openedRef.current) return;
|
||||
openedRef.current = true;
|
||||
Connection.OpenURL(uri).catch(reportOpenFailure);
|
||||
}, [uri, reportOpenFailure]);
|
||||
|
||||
const tryAgain = useCallback(() => {
|
||||
if (!uri) return;
|
||||
Connection.OpenURL(uri).catch(reportOpenFailure);
|
||||
}, [uri, reportOpenFailure]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
Events.Emit(EVENT_CANCEL).catch((err: unknown) =>
|
||||
console.error("emit browser-login cancel", err),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-browser-login-title"}>
|
||||
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-2"}>
|
||||
<DialogHeading id={"nb-browser-login-title"} className={"text-balance"}>
|
||||
{t("browserLogin.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{t("browserLogin.notSeeing")}{" "}
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={tryAgain}
|
||||
disabled={!uri}
|
||||
className={
|
||||
"wails-no-draggable text-netbird hover:underline disabled:cursor-not-allowed disabled:opacity-40"
|
||||
}
|
||||
>
|
||||
{t("browserLogin.tryAgain")}
|
||||
</button>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"secondary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={cancel}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import {
|
||||
startConnection,
|
||||
EVENT_BROWSER_LOGIN_CANCEL,
|
||||
EVENT_TRIGGER_LOGIN,
|
||||
} from "@/lib/connection.ts";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { shortenDns } from "@/lib/formatters";
|
||||
import { contentTop } from "@/components/empty-state/EmptyState";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
Connecting = "connecting",
|
||||
Connected = "connected",
|
||||
Disconnecting = "disconnecting",
|
||||
}
|
||||
|
||||
const STATUS_KEY: Record<ConnectionState, string> = {
|
||||
[ConnectionState.Disconnected]: "connect.status.disconnected",
|
||||
[ConnectionState.Connecting]: "connect.status.connecting",
|
||||
[ConnectionState.Connected]: "connect.status.connected",
|
||||
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
|
||||
};
|
||||
|
||||
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
|
||||
|
||||
const FORCE_TOGGLE_DELAY_MS = 7000;
|
||||
|
||||
const errorMessage = formatErrorMessage;
|
||||
|
||||
export const MainConnectionStatusSwitch = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status, refresh } = useStatus();
|
||||
const { activeProfileId, username } = useProfile();
|
||||
|
||||
const daemonState = status?.status ?? "Idle";
|
||||
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
|
||||
const unreachable = daemonState === "DaemonUnavailable";
|
||||
|
||||
type Action = "connect" | "logging-in" | "disconnect" | null;
|
||||
const [action, setAction] = useState<Action>(null);
|
||||
|
||||
const loginGuard = useRef(false);
|
||||
const driveLogin = useCallback(() => {
|
||||
if (loginGuard.current) return;
|
||||
loginGuard.current = true;
|
||||
setAction("logging-in");
|
||||
void startConnection(() => {
|
||||
loginGuard.current = false;
|
||||
setAction(null);
|
||||
refresh().catch((err: unknown) => console.error("refresh after login failed", err));
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
const connState: ConnectionState = useMemo(() => {
|
||||
if (action === "disconnect" && daemonState === "Connected") {
|
||||
return ConnectionState.Disconnecting;
|
||||
}
|
||||
if ((action === "connect" || action === "logging-in") && daemonState !== "Connected") {
|
||||
return ConnectionState.Connecting;
|
||||
}
|
||||
switch (daemonState) {
|
||||
case "Connected":
|
||||
return ConnectionState.Connected;
|
||||
case "Connecting":
|
||||
return ConnectionState.Connecting;
|
||||
case "Idle":
|
||||
case "NeedsLogin":
|
||||
case "LoginFailed":
|
||||
case "SessionExpired":
|
||||
case "DaemonUnavailable":
|
||||
return ConnectionState.Disconnected;
|
||||
default:
|
||||
return ConnectionState.Disconnected;
|
||||
}
|
||||
}, [daemonState, action]);
|
||||
|
||||
const connect = async () => {
|
||||
setAction("connect");
|
||||
try {
|
||||
await Connection.Up({
|
||||
profileName: activeProfileId,
|
||||
username,
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setAction(null);
|
||||
await refresh();
|
||||
await errorDialog({
|
||||
Title: t("connect.error.connectTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = async () => {
|
||||
setAction("disconnect");
|
||||
try {
|
||||
await Connection.Down();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setAction(null);
|
||||
await refresh();
|
||||
await errorDialog({
|
||||
Title: t("connect.error.disconnectTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sawConnectingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (action === null) {
|
||||
sawConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (daemonState === "Connecting") {
|
||||
sawConnectingRef.current = true;
|
||||
}
|
||||
if (action === "connect") {
|
||||
if (needsLogin) {
|
||||
driveLogin();
|
||||
return;
|
||||
}
|
||||
if (daemonState === "Connected" || unreachable) {
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
if (sawConnectingRef.current && daemonState === "Idle") {
|
||||
setAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "disconnect") {
|
||||
if (daemonState === "Idle" || daemonState === "Disconnected" || unreachable) {
|
||||
setAction(null);
|
||||
}
|
||||
}
|
||||
}, [action, daemonState, needsLogin, unreachable, driveLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
const off = Events.On(EVENT_TRIGGER_LOGIN, () => {
|
||||
driveLogin();
|
||||
});
|
||||
return () => off();
|
||||
}, [driveLogin]);
|
||||
|
||||
const handleSwitch = (next: boolean) => {
|
||||
if (unreachable) return;
|
||||
if (isTransitioning) {
|
||||
if (canForceCancel) void forceCancel();
|
||||
return;
|
||||
}
|
||||
if (action !== null) return;
|
||||
if (needsLogin) {
|
||||
driveLogin();
|
||||
return;
|
||||
}
|
||||
if (next && connState === ConnectionState.Disconnected) {
|
||||
void connect();
|
||||
} else if (!next && connState === ConnectionState.Connected) {
|
||||
void disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const isTransitioning =
|
||||
connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting;
|
||||
const isOn =
|
||||
connState === ConnectionState.Connected || connState === ConnectionState.Connecting;
|
||||
|
||||
const [canForceCancel, setCanForceCancel] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isTransitioning) {
|
||||
setCanForceCancel(false);
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setCanForceCancel(true), FORCE_TOGGLE_DELAY_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [isTransitioning]);
|
||||
|
||||
const forceCancel = async () => {
|
||||
if (action === "logging-in") {
|
||||
Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) =>
|
||||
console.error("emit browser-login cancel failed", err),
|
||||
);
|
||||
}
|
||||
WindowManager.CloseBrowserLogin().catch((err: unknown) =>
|
||||
console.warn("close browser-login window failed", err),
|
||||
);
|
||||
setAction("disconnect");
|
||||
try {
|
||||
await Connection.Down();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setAction(null);
|
||||
await refresh();
|
||||
await errorDialog({
|
||||
Title: t("connect.error.disconnectTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
const show = connState === ConnectionState.Connected;
|
||||
const fqdn = status?.local.fqdn || "";
|
||||
const ip = status?.local.ip || "";
|
||||
const ipv6 = status?.local.ipv6 || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex h-full w-full flex-col items-center gap-4", "relative")}
|
||||
style={{ top: contentTop("11.7rem") }}
|
||||
>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
alt={"NetBird"}
|
||||
className={"wails-no-draggable mb-4 h-7 w-auto select-none"}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
size={"large"}
|
||||
checked={isOn}
|
||||
onCheckedChange={handleSwitch}
|
||||
disabled={(isTransitioning && !canForceCancel) || unreachable}
|
||||
aria-label={t("connect.toggle.label")}
|
||||
aria-describedby={"nb-connection-status"}
|
||||
aria-busy={isTransitioning}
|
||||
className={cn(unreachable && "opacity-80", isTransitioning && "animate-pulse")}
|
||||
/>
|
||||
|
||||
<div className={"flex flex-col items-center"}>
|
||||
<p
|
||||
id={"nb-connection-status"}
|
||||
role={"status"}
|
||||
aria-live={"polite"}
|
||||
className={
|
||||
"wails-no-draggable mb-1 select-none text-sm font-medium tracking-wide text-nb-gray-200 transition-colors duration-300"
|
||||
}
|
||||
>
|
||||
{t(STATUS_KEY[connState])}
|
||||
</p>
|
||||
<CopyToClipboard
|
||||
message={fqdn}
|
||||
variant={"bright"}
|
||||
iconClassName={"-top-px"}
|
||||
tabIndex={show && fqdn ? 0 : -1}
|
||||
className={cn(
|
||||
"mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300",
|
||||
"relative left-[0.55rem]",
|
||||
show && fqdn ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<TruncatedText
|
||||
text={shortenDns(fqdn) || " "}
|
||||
className={
|
||||
"block h-[18px] max-w-[310px] truncate font-mono text-[0.8rem] leading-tight text-nb-gray-300"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
<LocalIpLine ip={ip} ipv6={ipv6} show={show} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const isFocusVisible = useFocusVisible();
|
||||
const hasV6 = !!ipv6;
|
||||
|
||||
if (!hasV6) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
message={ip}
|
||||
variant={"bright"}
|
||||
tabIndex={show && ip ? 0 : -1}
|
||||
className={cn(
|
||||
"mt-1 max-h-[1em] min-h-[1em] transition-opacity duration-300",
|
||||
"relative left-[0.55rem]",
|
||||
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
|
||||
{ip || " "}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-[1em] max-w-full transition-opacity duration-300",
|
||||
"wails-no-draggable relative",
|
||||
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={show && ip ? 0 : -1}
|
||||
aria-label={t("connect.localIp.label")}
|
||||
aria-haspopup={"dialog"}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"group relative inline-flex cursor-default items-center rounded-sm outline-none",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[0.8rem] leading-tight text-nb-gray-300 transition-colors",
|
||||
"group-hover:text-nb-gray-200",
|
||||
"group-data-[state=open]:text-nb-gray-200",
|
||||
)}
|
||||
>
|
||||
{ip || " "}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"absolute -right-5 top-1/2 -translate-y-1/2",
|
||||
"shrink-0 text-nb-gray-300 transition-colors",
|
||||
"group-hover:text-nb-gray-200",
|
||||
"group-data-[state=open]:text-nb-gray-200",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side={"bottom"}
|
||||
align={"center"}
|
||||
sideOffset={6}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 min-w-64 max-w-[280px] overflow-hidden",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-1 text-nb-gray-200 shadow-lg outline-none",
|
||||
"flex flex-col",
|
||||
)}
|
||||
>
|
||||
<IpRow value={ip} />
|
||||
<div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />
|
||||
<IpRow value={ipv6} />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const IpRow = ({ value }: { value: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isFocusVisible = useFocusVisible();
|
||||
const handleClick = async () => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 500);
|
||||
} catch (e) {
|
||||
console.warn("copy IP to clipboard failed", e);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={handleClick}
|
||||
tabIndex={0}
|
||||
aria-label={`${t("common.copy")} ${value}`}
|
||||
className={cn(
|
||||
"group/iprow relative flex items-center justify-between gap-3",
|
||||
"rounded-md px-2 py-1.5 text-left",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"cursor-default outline-none transition-colors",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={"inline-flex shrink-0 items-center text-nb-gray-200"}
|
||||
>
|
||||
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
256
client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
Normal file
256
client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { forwardRef, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Check, ChevronsUpDown, type LucideProps, SquareArrowUpRight } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export const MainExitNodeSwitcher = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { exitNodes, toggleExitNode } = useNetworks();
|
||||
const active = exitNodes.find((n) => n.selected) ?? null;
|
||||
const isConnected = status?.status === "Connected";
|
||||
const hasAny = exitNodes.length > 0;
|
||||
const disabled = !isConnected || !hasAny;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (open || disabled) return;
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (next: string) => {
|
||||
setOpen(false);
|
||||
if (next === NONE_VALUE) {
|
||||
if (active)
|
||||
toggleExitNode(active.id, true).catch((err: unknown) =>
|
||||
console.error("toggle exit node failed", err),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (active?.id === next) return;
|
||||
toggleExitNode(next, false).catch((err: unknown) =>
|
||||
console.error("toggle exit node failed", err),
|
||||
);
|
||||
};
|
||||
|
||||
const title = active ? active.id : t("exitNodes.card.title");
|
||||
const activeDescription = active
|
||||
? t("exitNodes.card.statusActive")
|
||||
: t("exitNodes.card.statusInactive");
|
||||
const description = hasAny ? activeDescription : t("exitNodes.empty.title");
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"}>
|
||||
<ExitNodeTriggerCard
|
||||
title={title}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
active={!!active}
|
||||
aria-label={t("exitNodes.dropdown.trigger")}
|
||||
aria-haspopup={"listbox"}
|
||||
aria-expanded={open}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align={"center"}
|
||||
side={"top"}
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
listRef.current?.focus();
|
||||
}}
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
className={cn(
|
||||
"wails-no-draggable z-50 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
shouldFilter={false}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={"outline-none focus:outline-none focus-visible:outline-none"}
|
||||
>
|
||||
<Command.List
|
||||
ref={listRef}
|
||||
aria-label={t("exitNodes.dropdown.trigger")}
|
||||
className={"outline-none focus:outline-none focus-visible:outline-none"}
|
||||
>
|
||||
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
|
||||
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
|
||||
{hasAny && (
|
||||
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"max-h-72 px-1"}>
|
||||
{exitNodes.map((n) => (
|
||||
<ExitNodeRow
|
||||
key={n.id}
|
||||
id={n.id}
|
||||
label={n.id}
|
||||
isActive={active?.id === n.id}
|
||||
onSelect={() => handleSelect(n.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
title: string;
|
||||
description: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
function ExitNodeTriggerCard(
|
||||
{ title, description, disabled, active = false, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const isFocusVisible = useFocusVisible();
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"transition-colors duration-150",
|
||||
"wails-no-draggable",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-default hover:border-nb-gray-900 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-900 data-[state=open]:bg-nb-gray-935",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
|
||||
active
|
||||
? "bg-green-500/25 text-green-400"
|
||||
: "bg-nb-gray-900 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<ExitNodeIcon size={14} />
|
||||
</div>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<span className={"block truncate text-sm font-medium text-nb-gray-100"}>
|
||||
{title}
|
||||
</span>
|
||||
<TruncatedText
|
||||
text={description}
|
||||
className={
|
||||
"block max-w-full truncate text-[0.85rem] font-medium text-nb-gray-400"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ChevronsUpDown
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-400"}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type NoneRowProps = {
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Command.Item
|
||||
value={NONE_VALUE}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-2 pr-3",
|
||||
"cursor-default rounded-md text-sm outline-none",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
|
||||
{isActive && (
|
||||
<Check size={16} aria-hidden={"true"} className={"shrink-0 text-netbird"} />
|
||||
)}
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type ExitNodeRowProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
|
||||
<Command.Item
|
||||
value={id}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-2 pr-3",
|
||||
"cursor-default rounded-md text-sm outline-none",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
|
||||
{isActive && <Check size={16} aria-hidden={"true"} className={"shrink-0 text-netbird"} />}
|
||||
</Command.Item>
|
||||
);
|
||||
|
||||
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
|
||||
<SquareArrowUpRight
|
||||
{...props}
|
||||
size={typeof size === "number" ? size - 2 : size}
|
||||
className={cn("rotate-45", props.className)}
|
||||
/>
|
||||
);
|
||||
192
client/ui/frontend/src/modules/main/MainHeader.tsx
Normal file
192
client/ui/frontend/src/modules/main/MainHeader.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowUpCircleIcon,
|
||||
Check,
|
||||
MoreVertical,
|
||||
PanelsRightBottom,
|
||||
RectangleVertical,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import { IconButton } from "@/components/buttons/IconButton";
|
||||
import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown";
|
||||
import { useClientVersion } from "@/contexts/ClientVersionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
|
||||
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext";
|
||||
import { isWindows } from "@/lib/platform.ts";
|
||||
|
||||
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
|
||||
|
||||
export const MainHeader = () => {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useViewMode();
|
||||
const { updateAvailable } = useClientVersion();
|
||||
const { mdm, features } = useRestrictions();
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
setMenuOpen(false);
|
||||
WindowManager.OpenSettings("").catch((err: unknown) =>
|
||||
console.error("open settings window failed", err),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings);
|
||||
|
||||
const openAbout = () => {
|
||||
setMenuOpen(false);
|
||||
WindowManager.OpenSettings("about").catch((err: unknown) =>
|
||||
console.error("open settings (about) window failed", err),
|
||||
);
|
||||
};
|
||||
|
||||
const openManageProfiles = () => {
|
||||
WindowManager.OpenSettings("profiles").catch((err: unknown) =>
|
||||
console.error("open settings (profiles) window failed", err),
|
||||
);
|
||||
};
|
||||
|
||||
const selectMode = (mode: ViewMode) => {
|
||||
setMenuOpen(false);
|
||||
setViewMode(mode);
|
||||
};
|
||||
|
||||
const profileSlot = features.disableProfiles ? null : (
|
||||
<ProfileDropdown onManageProfiles={openManageProfiles} />
|
||||
);
|
||||
|
||||
const settingsSlot = (
|
||||
<div className={"relative"}>
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild className={"wails-no-draggable"}>
|
||||
<IconButton
|
||||
icon={MoreVertical}
|
||||
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
||||
className={"select-none"}
|
||||
aria-label={t("header.menu.open")}
|
||||
aria-haspopup={"menu"}
|
||||
aria-expanded={menuOpen}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={"end"}
|
||||
sideOffset={8}
|
||||
className={
|
||||
"min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0"
|
||||
}
|
||||
>
|
||||
{updateAvailable && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={openAbout}>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<ArrowUpCircleIcon
|
||||
size={14}
|
||||
className={"text-netbird"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
<span className={"text-netbird"}>
|
||||
{t("header.menu.updateAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={openSettings}>
|
||||
<div className={"flex w-full items-center gap-2"}>
|
||||
<Settings size={14} aria-hidden={"true"} />
|
||||
<span className={"flex-1"}>{t("header.menu.settings")}</span>
|
||||
<DropdownMenuShortcut>
|
||||
{formatShortcut(SETTINGS_SHORTCUT)}
|
||||
</DropdownMenuShortcut>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{!mdm.disableAdvancedView && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<ViewModeItem
|
||||
icon={RectangleVertical}
|
||||
label={t("header.menu.defaultView")}
|
||||
selected={viewMode === "default"}
|
||||
onSelect={() => selectMode("default")}
|
||||
/>
|
||||
<ViewModeItem
|
||||
icon={PanelsRightBottom}
|
||||
label={t("header.menu.advancedView")}
|
||||
selected={viewMode === "advanced"}
|
||||
onSelect={() => selectMode("advanced")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{updateAvailable && (
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={
|
||||
"pointer-events-none absolute right-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute inset-0 animate-ping rounded-full bg-netbird opacity-60"
|
||||
}
|
||||
/>
|
||||
<span className={"relative h-1.5 w-1.5 rounded-full bg-netbird"} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"wails-draggable relative z-10 shrink-0 cursor-default",
|
||||
"top-3 flex h-12 items-center",
|
||||
)}
|
||||
>
|
||||
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid shrink-0 grid-cols-3 items-center",
|
||||
isWindows() ? "w-[364px]" : "w-[380px]",
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<div className={"ml-4 flex justify-center"}>{profileSlot}</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
type ViewModeItemProps = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const ViewModeItem = ({ icon: Icon, label, selected, onSelect }: ViewModeItemProps) => (
|
||||
<DropdownMenuItem onClick={onSelect} role={"menuitemradio"} aria-checked={selected}>
|
||||
<div className={"flex w-full items-center gap-2"}>
|
||||
<Icon size={14} aria-hidden={"true"} />
|
||||
<span className={"flex-1"}>{label}</span>
|
||||
{selected && <Check size={14} className={"text-netbird"} aria-hidden={"true"} />}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
114
client/ui/frontend/src/modules/main/MainPage.tsx
Normal file
114
client/ui/frontend/src/modules/main/MainPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
|
||||
import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx";
|
||||
import { MainHeader } from "@/modules/main/MainHeader.tsx";
|
||||
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
|
||||
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext";
|
||||
import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext";
|
||||
import { useEffect } from "react";
|
||||
import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { Peers } from "@/modules/main/advanced/peers/Peers";
|
||||
import { Networks } from "@/modules/main/advanced/networks/Networks";
|
||||
import { NetworksProvider } from "@/contexts/NetworksContext";
|
||||
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext";
|
||||
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
|
||||
import { isWindows } from "@/lib/platform.ts";
|
||||
|
||||
export const MainPage = () => {
|
||||
return (
|
||||
<ViewModeProvider>
|
||||
<MainHeader />
|
||||
<NetworksProvider>
|
||||
<PeerDetailProvider>
|
||||
<MainBody />
|
||||
</PeerDetailProvider>
|
||||
</NetworksProvider>
|
||||
</ViewModeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const MainBody = () => {
|
||||
const { viewMode, setViewMode } = useViewMode();
|
||||
const { mdm, features } = useRestrictions();
|
||||
|
||||
// Force flip the view if MDM disabled advanced
|
||||
useEffect(() => {
|
||||
if (mdm.disableAdvancedView && viewMode === "advanced") {
|
||||
setViewMode("default");
|
||||
}
|
||||
}, [mdm.disableAdvancedView, viewMode, setViewMode]);
|
||||
|
||||
const isAdvanced = viewMode === "advanced";
|
||||
|
||||
return (
|
||||
<main className={"wails-draggable flex min-h-0 flex-1"}>
|
||||
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex shrink-0 flex-col items-center",
|
||||
isWindows() ? "w-[364px]" : "w-[380px]",
|
||||
)}
|
||||
>
|
||||
<MainConnectionStatusSwitch />
|
||||
{!features.disableNetworks && (
|
||||
<div className={"wails-no-draggable absolute bottom-5 left-5 right-5"}>
|
||||
<MainExitNodeSwitcher />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdvanced && (
|
||||
<NavSectionProvider>
|
||||
<AdvancedAppRightPanel />
|
||||
</NavSectionProvider>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const AdvancedAppRightPanel = () => {
|
||||
const { section } = useNavSection();
|
||||
const { selected } = usePeerDetail();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
|
||||
return (
|
||||
<AppRightPanel
|
||||
overlay={<PeerDetailPanel />}
|
||||
overlayOpen={selected !== null}
|
||||
className={"m-5 ml-0"}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
if (isConnected) el.removeAttribute("inert");
|
||||
else el.setAttribute("inert", "");
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col",
|
||||
!isConnected && "pointer-events-none select-none",
|
||||
)}
|
||||
aria-hidden={!isConnected}
|
||||
>
|
||||
<Navigation />
|
||||
<div
|
||||
role={"tabpanel"}
|
||||
id={`nb-tabpanel-${section}`}
|
||||
aria-labelledby={`nb-tab-${section}`}
|
||||
className={"flex min-h-0 flex-1 flex-col"}
|
||||
>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
</div>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
<div className={"pointer-events-auto absolute inset-0 z-20 flex bg-nb-gray-940"}>
|
||||
<NotConnectedState />
|
||||
</div>
|
||||
)}
|
||||
</AppRightPanel>
|
||||
);
|
||||
};
|
||||
134
client/ui/frontend/src/modules/main/advanced/Navigation.tsx
Normal file
134
client/ui/frontend/src/modules/main/advanced/Navigation.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext";
|
||||
|
||||
type TabEntry = {
|
||||
value: NavSection;
|
||||
label: string;
|
||||
icon: ComponentType<LucideProps>;
|
||||
};
|
||||
|
||||
export const Navigation = () => {
|
||||
const { t } = useTranslation();
|
||||
const { section, setSection } = useNavSection();
|
||||
const { status } = useStatus();
|
||||
const { features } = useRestrictions();
|
||||
const isConnected = status?.status === "Connected";
|
||||
|
||||
// Reset back to peers tab if mdm or feature flag flipped it
|
||||
useEffect(() => {
|
||||
if (features.disableNetworks && section === "networks") {
|
||||
setSection("peers");
|
||||
}
|
||||
}, [features.disableNetworks, section, setSection]);
|
||||
|
||||
const tabs: TabEntry[] = [
|
||||
{
|
||||
value: "peers",
|
||||
label: t("nav.peers.title"),
|
||||
icon: MonitorSmartphoneIcon,
|
||||
},
|
||||
];
|
||||
if (!features.disableNetworks) {
|
||||
tabs.push({
|
||||
value: "networks",
|
||||
label: t("nav.resources.title"),
|
||||
icon: Layers3Icon,
|
||||
});
|
||||
}
|
||||
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
const focusTab = (value: NavSection) => {
|
||||
setSection(value);
|
||||
requestAnimationFrame(() => tabRefs.current[value]?.focus());
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const enabled = tabs.filter((t) => isConnected || t.value === section);
|
||||
if (enabled.length < 2) return;
|
||||
const currentIndex = enabled.findIndex((t) => t.value === section);
|
||||
if (currentIndex === -1) return;
|
||||
let nextIndex: number;
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
nextIndex = (currentIndex + 1) % enabled.length;
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
nextIndex = (currentIndex - 1 + enabled.length) % enabled.length;
|
||||
break;
|
||||
case "Home":
|
||||
nextIndex = 0;
|
||||
break;
|
||||
case "End":
|
||||
nextIndex = enabled.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
focusTab(enabled[nextIndex].value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role={"tablist"}
|
||||
aria-orientation={"horizontal"}
|
||||
aria-label={t("nav.peers.title")}
|
||||
className={"wails-no-draggable flex shrink-0 items-stretch"}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.value === section;
|
||||
const isDisabled = !isConnected && !isActive;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === tabs.length - 1;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
ref={(el) => {
|
||||
tabRefs.current[tab.value] = el;
|
||||
}}
|
||||
type={"button"}
|
||||
role={"tab"}
|
||||
aria-selected={isActive}
|
||||
aria-controls={`nb-tabpanel-${tab.value}`}
|
||||
id={`nb-tab-${tab.value}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => setSection(tab.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"group relative flex flex-1 items-center justify-center",
|
||||
"gap-2.5 px-5 py-3.5",
|
||||
"outline-none transition-all",
|
||||
isFirst && "rounded-tl-xl",
|
||||
isLast && "rounded-tr-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden={"true"} />
|
||||
<span className={"text-sm font-normal"}>{tab.label}</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 h-px transition-all",
|
||||
isActive
|
||||
? "bg-netbird"
|
||||
: "bg-nb-gray-910 group-hover:bg-nb-gray-700",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type { NavSection } from "@/contexts/NavSectionContext";
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ChevronDown, ListFilter } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
|
||||
export type NetworkFilter = "all" | "active" | "overlapping";
|
||||
|
||||
type Props = {
|
||||
value: NetworkFilter;
|
||||
onChange: (value: NetworkFilter) => void;
|
||||
counts: Record<NetworkFilter, number>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const filters: { value: NetworkFilter; label: string }[] = [
|
||||
{ value: "all", label: t("networks.filter.all") },
|
||||
{ value: "active", label: t("networks.filter.active") },
|
||||
{ value: "overlapping", label: t("networks.filter.overlapping") },
|
||||
];
|
||||
const active = filters.find((f) => f.value === value) ?? filters[0];
|
||||
|
||||
const handleSelect = (v: NetworkFilter) => {
|
||||
onChange(v);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
tabIndex={0}
|
||||
aria-label={t("common.filter")}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<ListFilter size={14} aria-hidden={"true"} className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
const checked = f.value === value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={f.value}
|
||||
onClick={() => handleSelect(f.value)}
|
||||
role={"menuitemradio"}
|
||||
aria-checked={checked}
|
||||
className={"gap-2"}
|
||||
>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{f.label}{" "}
|
||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={"flex w-4 shrink-0 items-center justify-center"}
|
||||
>
|
||||
{checked && <CheckIcon size={14} className={"text-netbird"} />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,528 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { reconcileOrder } from "@/lib/sorting";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { type NetworkFilter, NetworkFilters } from "./NetworkFilters";
|
||||
|
||||
// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix".
|
||||
const INVALID_PREFIX = "invalid Prefix";
|
||||
|
||||
const isDnsRoute = (n: Network): boolean =>
|
||||
n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX);
|
||||
|
||||
type ResourceType = "host" | "subnet" | "domain";
|
||||
|
||||
const isHostCidr = (cidr: string): boolean => {
|
||||
const [addr, bitsStr] = cidr.split("/");
|
||||
if (!addr || !bitsStr) return false;
|
||||
const bits = Number(bitsStr);
|
||||
const isV6 = addr.includes(":");
|
||||
return isV6 ? bits === 128 : bits === 32;
|
||||
};
|
||||
|
||||
const resourceTypeOf = (n: Network): ResourceType => {
|
||||
if (isDnsRoute(n)) return "domain";
|
||||
const primary = n.range.split(",")[0].trim();
|
||||
return isHostCidr(primary) ? "host" : "subnet";
|
||||
};
|
||||
|
||||
const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
|
||||
if (type === "host") return WorkflowIcon;
|
||||
if (type === "domain") return GlobeIcon;
|
||||
return NetworkIcon;
|
||||
};
|
||||
|
||||
const buildOverlapMap = (
|
||||
routes: { id: string; range: string; domains: string[] }[],
|
||||
): Map<string, string[]> => {
|
||||
const byRange = new Map<string, string[]>();
|
||||
for (const r of routes) {
|
||||
if (r.domains.length > 0) continue;
|
||||
const arr = byRange.get(r.range) ?? [];
|
||||
arr.push(r.id);
|
||||
byRange.set(r.range, arr);
|
||||
}
|
||||
const out = new Map<string, string[]>();
|
||||
for (const [range, ids] of byRange) {
|
||||
if (ids.length > 1) out.set(range, ids);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const Networks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]);
|
||||
|
||||
const overlapById = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const ids of overlapGroups.values()) {
|
||||
for (const id of ids) map.set(id, ids);
|
||||
}
|
||||
return map;
|
||||
}, [overlapGroups]);
|
||||
|
||||
const counts = useMemo<Record<NetworkFilter, number>>(
|
||||
() => ({
|
||||
all: networkRoutes.length,
|
||||
active: networkRoutes.filter((r) => r.selected).length,
|
||||
overlapping: overlapById.size,
|
||||
}),
|
||||
[networkRoutes, overlapById],
|
||||
);
|
||||
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const { order, items } = reconcileOrder(
|
||||
orderRef.current,
|
||||
networkRoutes,
|
||||
(r) => r.id,
|
||||
(a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
},
|
||||
);
|
||||
orderRef.current = order;
|
||||
return items;
|
||||
}, [networkRoutes]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return ordered.filter((r) => {
|
||||
if (filter === "active" && !r.selected) return false;
|
||||
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
|
||||
if (q) {
|
||||
const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [ordered, search, filter, overlapById]);
|
||||
|
||||
if (isConnected && networkRoutes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Layers3Icon}
|
||||
title={t("networks.empty.title")}
|
||||
description={t("networks.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedInView = filtered.filter((r) => r.selected).length;
|
||||
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
|
||||
const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll");
|
||||
|
||||
const onBulkClick = () => {
|
||||
if (filtered.length === 0) return;
|
||||
if (allSelected) {
|
||||
setNetworksSelected(
|
||||
filtered.map((r) => r.id),
|
||||
false,
|
||||
).catch((err: unknown) => console.error("disable all networks failed", err));
|
||||
} else {
|
||||
const ids = filtered.filter((r) => !r.selected).map((r) => r.id);
|
||||
setNetworksSelected(ids, true).catch((err: unknown) =>
|
||||
console.error("enable all networks failed", err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex h-full min-h-0 w-full flex-col"}>
|
||||
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("networks.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<NetworkFilters value={filter} onChange={setFilter} counts={counts} />
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||
{scrollParent && (
|
||||
<NetworksList
|
||||
data={filtered}
|
||||
onToggle={toggleNetwork}
|
||||
scrollParent={scrollParent}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
)}
|
||||
{filtered.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-6 py-3.5",
|
||||
"border-t border-nb-gray-910",
|
||||
)}
|
||||
>
|
||||
<span className={"flex-1 text-xs font-medium tabular-nums text-nb-gray-300"}>
|
||||
{t("networks.bulk.selectionCount", {
|
||||
selected: selectedInView,
|
||||
total: filtered.length,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
onClick={onBulkClick}
|
||||
aria-label={t("networks.bulk.label")}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center rounded-md px-3",
|
||||
"text-xs font-medium text-nb-gray-100",
|
||||
"border border-nb-gray-900 bg-nb-gray-920 hover:border-nb-gray-850 hover:bg-nb-gray-910",
|
||||
"wails-no-draggable cursor-pointer outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{bulkLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type NetworksListProps = {
|
||||
data: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
scrollParent: HTMLElement;
|
||||
};
|
||||
|
||||
const NetworksHeader = () => <div className={"h-2"} />;
|
||||
|
||||
const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
const focusRow = (index: number) => {
|
||||
if (index < 0 || index >= data.length) return;
|
||||
const row = data[index];
|
||||
const tryFocus = () => {
|
||||
const el = rowRefs.current.get(row.id);
|
||||
if (el) {
|
||||
el.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!tryFocus()) {
|
||||
virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" });
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryFocus()) requestAnimationFrame(tryFocus);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<Element>, index: number) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
focusRow(Math.min(index + 1, data.length - 1));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
focusRow(Math.max(index - 1, 0));
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
focusRow(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
focusRow(data.length - 1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const setRowRef = (id: string, el: HTMLButtonElement | null) => {
|
||||
if (el) rowRefs.current.set(id, el);
|
||||
else rowRefs.current.delete(id);
|
||||
};
|
||||
|
||||
const ctx = useMemo<NetworkRowContext>(
|
||||
() => ({ onKeyDown: handleRowKeyDown, onToggle, setRowRef }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[data, onToggle],
|
||||
);
|
||||
|
||||
return (
|
||||
<Virtuoso<Network, NetworkRowContext>
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
customScrollParent={scrollParent}
|
||||
increaseViewportBy={400}
|
||||
computeItemKey={(_, n) => n.id}
|
||||
components={{ Header: NetworksHeader }}
|
||||
context={ctx}
|
||||
itemContent={renderNetworkRow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type NetworkRowContext = {
|
||||
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
setRowRef: (id: string, el: HTMLButtonElement | null) => void;
|
||||
};
|
||||
|
||||
const renderNetworkRow = (index: number, n: Network, ctx: NetworkRowContext): ReactNode => (
|
||||
<NetworkRow
|
||||
network={n}
|
||||
index={index}
|
||||
onKeyDown={ctx.onKeyDown}
|
||||
onToggle={ctx.onToggle}
|
||||
setRowRef={ctx.setRowRef}
|
||||
/>
|
||||
);
|
||||
|
||||
type NetworkRowProps = {
|
||||
network: Network;
|
||||
index: number;
|
||||
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
setRowRef: (id: string, el: HTMLButtonElement | null) => void;
|
||||
};
|
||||
|
||||
const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: NetworkRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Same handler is attached to the overlay button and to the network-id copy
|
||||
// button so arrow nav works wherever focus sits inside the row.
|
||||
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-9",
|
||||
"transition-colors hover:bg-nb-gray-900/40",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
ref={(el) => setRowRef(n.id, el)}
|
||||
aria-label={t("networks.row.toggle", { name: n.id })}
|
||||
aria-pressed={n.selected}
|
||||
onClick={() => onToggle(n.id, n.selected)}
|
||||
onKeyDown={handleKey}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-pointer outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
)}
|
||||
/>
|
||||
<ResourceIconBadge type={resourceTypeOf(n)} />
|
||||
<div
|
||||
className={
|
||||
"pointer-events-none relative flex min-w-0 flex-1 flex-col leading-tight"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<CopyToClipboard message={n.id} onKeyDown={handleKey}>
|
||||
<TruncatedText
|
||||
text={n.id}
|
||||
className={
|
||||
"block max-w-[300px] truncate text-[0.81rem] font-medium text-nb-gray-100"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<Subtitle network={n} onKeyDown={handleKey} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={"pointer-events-none relative shrink-0 self-center"}
|
||||
>
|
||||
<NetworkToggle checked={n.selected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
const Icon = resourceIconFor(type);
|
||||
return (
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"mt-[0.25rem] flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
|
||||
"border border-nb-gray-900 bg-nb-gray-920 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SubtitleProps = {
|
||||
network: Network;
|
||||
onKeyDown: (e: KeyboardEvent<Element>) => void;
|
||||
};
|
||||
|
||||
const Subtitle = ({ network, onKeyDown }: SubtitleProps) => {
|
||||
if (isDnsRoute(network)) {
|
||||
const domain = network.domains[0];
|
||||
const ips = network.resolvedIps[domain] ?? [];
|
||||
return <DomainSubtitle domain={domain} ips={ips} onKeyDown={onKeyDown} />;
|
||||
}
|
||||
|
||||
if (network.range && network.range !== INVALID_PREFIX) {
|
||||
return (
|
||||
<div>
|
||||
<CopyToClipboard message={network.range} onKeyDown={onKeyDown}>
|
||||
<TruncatedText
|
||||
text={network.range}
|
||||
className={
|
||||
"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type DomainSubtitleProps = {
|
||||
domain: string;
|
||||
ips: string[];
|
||||
onKeyDown: (e: KeyboardEvent<Element>) => void;
|
||||
};
|
||||
|
||||
const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
|
||||
const span = (
|
||||
<span className={"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"}>
|
||||
{domain}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<CopyToClipboard message={domain} onKeyDown={onKeyDown}>
|
||||
{ips.length > 0 ? (
|
||||
<Tooltip
|
||||
content={<ResolvedIpsTooltip ips={ips} />}
|
||||
delayDuration={300}
|
||||
closeDelay={300}
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
alignOffset={-8}
|
||||
interactive
|
||||
keepOpenOnClick
|
||||
contentClassName={cn(
|
||||
"max-h-72 max-w-[18rem] overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 pr-4",
|
||||
)}
|
||||
>
|
||||
{span}
|
||||
</Tooltip>
|
||||
) : (
|
||||
span
|
||||
)}
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div className={"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-300"}>
|
||||
{t("networks.ips.heading")}
|
||||
</div>
|
||||
<ul className={"flex flex-col"}>
|
||||
{ips.map((ip) => (
|
||||
<li key={ip}>
|
||||
<CopyToClipboard message={ip} className={"px-1 py-0.5"}>
|
||||
<span
|
||||
className={
|
||||
"whitespace-nowrap font-mono text-[0.72rem] text-nb-gray-100"
|
||||
}
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ToggleProps = {
|
||||
checked: boolean;
|
||||
mixed?: boolean;
|
||||
};
|
||||
|
||||
const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
|
||||
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-5 w-9 shrink-0 items-center rounded-full",
|
||||
"wails-no-draggable transition-colors",
|
||||
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
|
||||
mixed && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
mixed ? "translate-x-2.5" : checkedTranslate,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,539 @@
|
||||
import {
|
||||
type ComponentType,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsLeftRightEllipsisIcon,
|
||||
ClockIcon,
|
||||
GaugeIcon,
|
||||
HandshakeIcon,
|
||||
KeyRoundIcon,
|
||||
Layers3Icon,
|
||||
type LucideProps,
|
||||
MapPinIcon,
|
||||
MonitorIcon,
|
||||
Radio,
|
||||
RefreshCwIcon,
|
||||
WaypointsIcon,
|
||||
} from "lucide-react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { peerStatusLabelKey } from "./Peers";
|
||||
|
||||
const DEFAULT_TRANSITION: Transition = {
|
||||
duration: 0.32,
|
||||
ease: [0.32, 0.72, 0, 1],
|
||||
};
|
||||
|
||||
const DASH = "-";
|
||||
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { selected, setSelected } = usePeerDetail();
|
||||
const { status, refresh } = useStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const peers = status?.peers ?? [];
|
||||
const fresh = peers.find((p) => p.pubKey === selected.pubKey);
|
||||
if (!fresh) {
|
||||
setSelected(null);
|
||||
return;
|
||||
}
|
||||
if (fresh !== selected) setSelected(fresh);
|
||||
}, [status, selected, setSelected]);
|
||||
|
||||
// Daemon updates latency/bytes/handshake without pushing a fresh status
|
||||
// snapshot, so tick locally to keep relative timestamps live.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [selected]);
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (refreshing) return;
|
||||
setRefreshing(true);
|
||||
const MIN_SPIN_MS = 600;
|
||||
const minDelay = new Promise<void>((r) => setTimeout(r, MIN_SPIN_MS));
|
||||
try {
|
||||
await Promise.all([refresh(), minDelay]);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refresh, refreshing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setSelected(null);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return;
|
||||
setSelected(null);
|
||||
}
|
||||
};
|
||||
globalThis.addEventListener("keydown", onKey);
|
||||
return () => globalThis.removeEventListener("keydown", onKey);
|
||||
}, [selected, setSelected]);
|
||||
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const backButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
// Defer focus until the slide-in animation has started rendering.
|
||||
// preventScroll avoids the browser scrolling the parent to chase the
|
||||
// still-offscreen button, which lands as a stutter at the end of the slide.
|
||||
requestAnimationFrame(() => backButtonRef.current?.focus({ preventScroll: true }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected?.pubKey]);
|
||||
|
||||
const getFocusable = (): HTMLElement[] => {
|
||||
const root = dialogRef.current;
|
||||
if (!root) return [];
|
||||
const sel =
|
||||
"button:not([disabled]), [href], input:not([disabled]), select:not([disabled])," +
|
||||
' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(sel)).filter(
|
||||
(el) => el.offsetParent !== null || el === document.activeElement,
|
||||
);
|
||||
};
|
||||
|
||||
const onDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const focusables = getFocusable();
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey) {
|
||||
if (active === first || !active || !dialogRef.current?.contains(active)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<motion.div
|
||||
ref={dialogRef}
|
||||
role={"dialog"}
|
||||
aria-modal={"true"}
|
||||
aria-labelledby={"nb-peer-detail-title"}
|
||||
onKeyDown={onDialogKeyDown}
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={transition}
|
||||
style={{ willChange: "transform" }}
|
||||
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-3",
|
||||
"h-12 border-b border-nb-gray-910 px-3",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
ref={backButtonRef}
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
onClick={() => setSelected(null)}
|
||||
aria-label={t("common.close")}
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<ArrowLeftIcon size={16} aria-hidden={"true"} />
|
||||
</button>
|
||||
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
dotClass(selected.connStatus),
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<CopyToClipboard
|
||||
message={selected.fqdn || selected.ip}
|
||||
size={11}
|
||||
className={"min-w-0 flex-1"}
|
||||
iconClassName={"top-[2px]"}
|
||||
>
|
||||
<span
|
||||
id={"nb-peer-detail-title"}
|
||||
className={"truncate text-sm font-medium text-nb-gray-100"}
|
||||
>
|
||||
{shortenDns(selected.fqdn) || selected.ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
<Tooltip content={t("peers.details.refresh")}>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
aria-label={t("peers.details.refresh")}
|
||||
aria-busy={refreshing}
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={refreshing ? "animate-spin" : undefined}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<PeerDetails peer={selected} now={now} />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const formatAge = (unix: number, fallback: string): string => {
|
||||
if (!Number.isFinite(unix) || unix <= 0) return fallback;
|
||||
const diff = Math.floor(now / 1000 - unix);
|
||||
if (diff < 1) return t("peers.details.justNow");
|
||||
return formatRelative(unix, now) ?? fallback;
|
||||
};
|
||||
const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never"));
|
||||
const statusSince = formatAge(peer.connStatusUpdateUnix, DASH);
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p");
|
||||
|
||||
return (
|
||||
<ul className={"flex flex-col divide-y divide-nb-gray-920"}>
|
||||
<Row icon={MapPinIcon} label={t("peers.details.netbirdIp")}>
|
||||
{peer.ip ? (
|
||||
<CopyToClipboard
|
||||
message={peer.ip}
|
||||
alwaysShowIcon
|
||||
className={"max-w-full"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<span className={"font-mono"}>{peer.ip}</span>
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
{peer.ipv6 && (
|
||||
<Row icon={MapPinIcon} label={t("peers.details.netbirdIpv6")}>
|
||||
<CopyToClipboard
|
||||
message={peer.ipv6}
|
||||
alwaysShowIcon
|
||||
className={"min-w-0 max-w-full"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.ipv6} mono />
|
||||
</CopyToClipboard>
|
||||
</Row>
|
||||
)}
|
||||
{isConnected && (
|
||||
<Row icon={ChevronsLeftRightEllipsisIcon} label={t("peers.details.connection")}>
|
||||
<span className={"whitespace-nowrap"}>{connectionLabel}</span>
|
||||
</Row>
|
||||
)}
|
||||
{peer.relayed && (
|
||||
<Row icon={WaypointsIcon} label={t("peers.details.relayAddress")}>
|
||||
{peer.relayAddress ? (
|
||||
<CopyToClipboard
|
||||
message={peer.relayAddress}
|
||||
alwaysShowIcon
|
||||
className={"min-w-0 max-w-full"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.relayAddress} mono />
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{peer.latencyMs > 0 && (
|
||||
<Row icon={GaugeIcon} label={t("peers.details.latency")}>
|
||||
<span className={cn("tabular-nums", latencyColor(peer.latencyMs))}>
|
||||
{peer.latencyMs} ms
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
{(peer.bytesRx > 0 || peer.bytesTx > 0) && (
|
||||
<Row icon={ArrowUpDownIcon} label={t("peers.details.bytes")}>
|
||||
<div
|
||||
className={
|
||||
"flex items-center justify-end gap-3 font-medium text-nb-gray-300"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-1.5 whitespace-nowrap"}>
|
||||
<ArrowDownIcon
|
||||
size={13}
|
||||
aria-hidden={"true"}
|
||||
className={"text-sky-400"}
|
||||
/>
|
||||
<span className={"sr-only"}>{t("peers.details.bytesReceived")}:</span>
|
||||
<span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span>
|
||||
</div>
|
||||
<div className={"flex items-center gap-1.5 whitespace-nowrap"}>
|
||||
<ArrowUpIcon
|
||||
size={13}
|
||||
aria-hidden={"true"}
|
||||
className={"text-netbird"}
|
||||
/>
|
||||
<span className={"sr-only"}>{t("peers.details.bytesSent")}:</span>
|
||||
<span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
<Row icon={HandshakeIcon} label={t("peers.details.lastHandshake")}>
|
||||
{lastHandshake}
|
||||
</Row>
|
||||
<Row icon={ClockIcon} label={t("peers.details.statusSince")}>
|
||||
{statusSince}
|
||||
</Row>
|
||||
{peer.networks.length > 0 && (
|
||||
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
|
||||
<ResourcesValue networks={peer.networks} />
|
||||
</Row>
|
||||
)}
|
||||
<IceRow
|
||||
icon={MonitorIcon}
|
||||
baseLabel={t("peers.details.localIce")}
|
||||
type={peer.localIceCandidateType}
|
||||
endpoint={peer.localIceCandidateEndpoint}
|
||||
/>
|
||||
<IceRow
|
||||
icon={Radio}
|
||||
baseLabel={t("peers.details.remoteIce")}
|
||||
type={peer.remoteIceCandidateType}
|
||||
endpoint={peer.remoteIceCandidateEndpoint}
|
||||
/>
|
||||
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
|
||||
{peer.pubKey ? (
|
||||
<CopyToClipboard
|
||||
message={peer.pubKey}
|
||||
alwaysShowIcon
|
||||
className={"min-w-0 max-w-full"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.pubKey} mono />
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
type RowProps = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
iconClassName?: string;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type IceRowProps = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
baseLabel: string;
|
||||
type: string;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
const capitalize = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
|
||||
const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
|
||||
if (!type && !endpoint) return null;
|
||||
const label = type ? `${baseLabel} (${capitalize(type)})` : baseLabel;
|
||||
return (
|
||||
<Row icon={icon} label={label}>
|
||||
{endpoint ? (
|
||||
<CopyToClipboard
|
||||
message={endpoint}
|
||||
alwaysShowIcon
|
||||
className={"min-w-0 max-w-full"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={endpoint} mono />
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
<span className={"truncate"}>{capitalize(type)}</span>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourcesValue = ({ networks }: { networks: string[] }) => (
|
||||
<ResourcesPopover networks={networks} />
|
||||
);
|
||||
|
||||
const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
aria-haspopup={"dialog"}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded",
|
||||
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
|
||||
"border border-nb-gray-900",
|
||||
"px-2 py-1 text-xs font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none transition-all",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{networks.length}
|
||||
<ChevronDownIcon
|
||||
size={12}
|
||||
aria-hidden={"true"}
|
||||
className={cn("transition-transform duration-150", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side={"bottom"}
|
||||
align={"end"}
|
||||
sideOffset={6}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 max-h-72 max-w-[18rem] overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 pr-4 shadow-lg outline-none",
|
||||
)}
|
||||
>
|
||||
<ul className={"flex flex-col"}>
|
||||
{networks.map((n) => (
|
||||
<li key={n}>
|
||||
<CopyToClipboard message={n} className={"px-1 py-0.5"}>
|
||||
<span
|
||||
className={
|
||||
"whitespace-nowrap font-mono text-[0.72rem] text-nb-gray-200"
|
||||
}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
|
||||
<TruncatedText
|
||||
text={value}
|
||||
className={cn(
|
||||
"inline-block min-w-0 max-w-[260px] truncate align-middle",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
|
||||
<li className={"flex min-w-0 items-center gap-2 px-5 py-4 text-xs text-nb-gray-100"}>
|
||||
<Icon
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={cn("shrink-0 text-nb-gray-100", iconClassName)}
|
||||
/>
|
||||
<span className={"shrink-0 font-semibold text-nb-gray-200"}>{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 pl-8 text-right",
|
||||
"font-medium text-nb-gray-350",
|
||||
"flex items-center justify-end",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ChevronDown, ListFilter } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
|
||||
export type StatusFilter = "all" | "online" | "offline";
|
||||
|
||||
type Props = {
|
||||
value: StatusFilter;
|
||||
onChange: (value: StatusFilter) => void;
|
||||
counts: Record<StatusFilter, number>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const filters: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "all", label: t("peers.filter.all") },
|
||||
{ value: "online", label: t("peers.filter.online") },
|
||||
{ value: "offline", label: t("peers.filter.offline") },
|
||||
];
|
||||
const active = filters.find((f) => f.value === value) ?? filters[0];
|
||||
|
||||
const handleSelect = (v: StatusFilter) => {
|
||||
onChange(v);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
tabIndex={0}
|
||||
aria-label={t("common.filter")}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<ListFilter size={14} aria-hidden={"true"} className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
const checked = f.value === value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={f.value}
|
||||
onClick={() => handleSelect(f.value)}
|
||||
role={"menuitemradio"}
|
||||
aria-checked={checked}
|
||||
className={"gap-2"}
|
||||
>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{f.label}{" "}
|
||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={"flex w-4 shrink-0 items-center justify-center"}
|
||||
>
|
||||
{checked && <CheckIcon size={14} className={"text-netbird"} />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
353
client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx
Normal file
353
client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { reconcileOrder } from "@/lib/sorting";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { latencyColor, shortenDns } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { PeerFilters, type StatusFilter } from "./PeerFilters";
|
||||
|
||||
const isOnline = (connStatus: string) => connStatus === "Connected";
|
||||
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
export const peerStatusLabelKey = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "peers.status.connected";
|
||||
case "Connecting":
|
||||
return "peers.status.connecting";
|
||||
default:
|
||||
return "peers.status.disconnected";
|
||||
}
|
||||
};
|
||||
|
||||
export const Peers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const isConnected = status?.status === "Connected";
|
||||
const peers = useMemo(() => status?.peers ?? [], [status?.peers]);
|
||||
|
||||
const counts = useMemo<Record<StatusFilter, number>>(() => {
|
||||
const online = peers.filter((p) => isOnline(p.connStatus)).length;
|
||||
return {
|
||||
all: peers.length,
|
||||
online,
|
||||
offline: peers.length - online,
|
||||
};
|
||||
}, [peers]);
|
||||
|
||||
// Stay in live-sort until every peer is stable. Right after Up the daemon
|
||||
// emits all peers as "Connecting"; committing then would lock that
|
||||
// alphabetical-only order forever.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const stickyRef = useRef(false);
|
||||
const ordered = useMemo(() => {
|
||||
const compare = (a: PeerStatus, b: PeerStatus) => {
|
||||
const aOnline = isOnline(a.connStatus);
|
||||
const bOnline = isOnline(b.connStatus);
|
||||
if (aOnline !== bOnline) return aOnline ? -1 : 1;
|
||||
const aName = (a.fqdn || a.ip).toLowerCase();
|
||||
const bName = (b.fqdn || b.ip).toLowerCase();
|
||||
return aName.localeCompare(bName);
|
||||
};
|
||||
|
||||
if (peers.length === 0) {
|
||||
orderRef.current = [];
|
||||
stickyRef.current = false;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!stickyRef.current) {
|
||||
const sorted = [...peers].sort(compare);
|
||||
if (peers.every((p) => p.connStatus !== "Connecting")) {
|
||||
orderRef.current = sorted.map((p) => p.pubKey);
|
||||
stickyRef.current = true;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare);
|
||||
orderRef.current = order;
|
||||
return items;
|
||||
}, [peers]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return ordered.filter((p) => {
|
||||
if (statusFilter === "online" && !isOnline(p.connStatus)) return false;
|
||||
if (statusFilter === "offline" && isOnline(p.connStatus)) return false;
|
||||
return !q || p.fqdn.toLowerCase().includes(q) || p.ip.includes(q);
|
||||
});
|
||||
}, [ordered, search, statusFilter]);
|
||||
|
||||
if (isConnected && peers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={MonitorSmartphoneIcon}
|
||||
title={t("peers.empty.title")}
|
||||
description={t("peers.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"flex h-full min-h-0 w-full flex-col"}>
|
||||
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("peers.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<PeerFilters value={statusFilter} onChange={setStatusFilter} counts={counts} />
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListTopSpacer = () => <div className={"h-2"} />;
|
||||
|
||||
type PeersListProps = {
|
||||
data: PeerStatus[];
|
||||
scrollParent: HTMLElement;
|
||||
};
|
||||
|
||||
const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
const { setSelected } = usePeerDetail();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
const focusRow = (index: number) => {
|
||||
if (index < 0 || index >= data.length) return;
|
||||
const peer = data[index];
|
||||
const tryFocus = () => {
|
||||
const el = rowRefs.current.get(peer.pubKey);
|
||||
if (el) {
|
||||
el.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!tryFocus()) {
|
||||
virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" });
|
||||
// Row may not be mounted yet — retry after Virtuoso renders it.
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryFocus()) requestAnimationFrame(tryFocus);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<Element>, index: number) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
focusRow(Math.min(index + 1, data.length - 1));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
focusRow(Math.max(index - 1, 0));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
setSelected(data[index]);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
focusRow(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
focusRow(data.length - 1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const setRowRef = (pubKey: string, el: HTMLButtonElement | null) => {
|
||||
if (el) rowRefs.current.set(pubKey, el);
|
||||
else rowRefs.current.delete(pubKey);
|
||||
};
|
||||
|
||||
const ctx = useMemo<PeerRowContext>(
|
||||
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[data, setSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<Virtuoso<PeerStatus, PeerRowContext>
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
customScrollParent={scrollParent}
|
||||
increaseViewportBy={400}
|
||||
computeItemKey={(_, peer) => peer.pubKey}
|
||||
components={{ Header: ListTopSpacer }}
|
||||
context={ctx}
|
||||
itemContent={renderPeerRow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type PeerRowContext = {
|
||||
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
|
||||
onSelect: (peer: PeerStatus) => void;
|
||||
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
|
||||
};
|
||||
|
||||
const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => (
|
||||
<PeerRow
|
||||
peer={peer}
|
||||
index={index}
|
||||
onKeyDown={ctx.onKeyDown}
|
||||
onSelect={ctx.onSelect}
|
||||
setRowRef={ctx.setRowRef}
|
||||
/>
|
||||
);
|
||||
|
||||
type PeerRowProps = {
|
||||
peer: PeerStatus;
|
||||
index: number;
|
||||
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
|
||||
onSelect: (peer: PeerStatus) => void;
|
||||
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
|
||||
};
|
||||
|
||||
const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
const peerName = shortenDns(peer.fqdn) || peer.ip;
|
||||
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
|
||||
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
|
||||
"transition-colors hover:bg-nb-gray-900/40",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
ref={(el) => setRowRef(peer.pubKey, el)}
|
||||
aria-label={t("peers.row.label", { name: peerName, status: statusLabel })}
|
||||
onClick={() => onSelect(peer)}
|
||||
onKeyDown={handleKey}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-default outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
)}
|
||||
/>
|
||||
<Tooltip content={statusLabel} side={"left"}>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"relative mt-2 h-2 w-2 shrink-0 rounded-full",
|
||||
dotClass(peer.connStatus),
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className={
|
||||
"pointer-events-none relative flex min-w-0 flex-1 flex-col leading-tight"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<CopyToClipboard
|
||||
message={peer.fqdn}
|
||||
className={"pointer-events-auto"}
|
||||
onKeyDown={handleKey}
|
||||
>
|
||||
<TruncatedText
|
||||
text={shortenDns(peer.fqdn)}
|
||||
className={
|
||||
"block max-w-[300px] truncate text-[0.81rem] font-medium text-nb-gray-100"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div>
|
||||
<CopyToClipboard
|
||||
message={peer.ip}
|
||||
className={"pointer-events-auto"}
|
||||
onKeyDown={handleKey}
|
||||
>
|
||||
<span className={"truncate font-mono text-xs text-nb-gray-400"}>
|
||||
{peer.ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
{isConnected && peer.latencyMs > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none relative shrink-0 self-center text-xs tabular-nums",
|
||||
latencyColor(peer.latencyMs),
|
||||
)}
|
||||
>
|
||||
{peer.latencyMs} ms
|
||||
</span>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"pointer-events-none relative shrink-0 self-center text-nb-gray-300",
|
||||
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
76
client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx
Normal file
76
client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { type ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import {
|
||||
Briefcase,
|
||||
Building,
|
||||
Cloud,
|
||||
Construction,
|
||||
FlaskConical,
|
||||
Gamepad2,
|
||||
GraduationCap,
|
||||
House,
|
||||
Radio,
|
||||
Server,
|
||||
SquareCode,
|
||||
Terminal,
|
||||
UserCircle,
|
||||
UserPlus,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Scanned in order — put more-specific tokens first (e.g. "staging" before "stage").
|
||||
const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [
|
||||
[/(default|personal)/i, UserCircle],
|
||||
[/(work|business|office|company|corp|corporate)/i, Briefcase],
|
||||
[/(home|house|private)/i, House],
|
||||
[/(dev|development|developer|code|coding|engineering)/i, SquareCode],
|
||||
[/(local|localhost|loopback)/i, Terminal],
|
||||
[/(stage|staging|preprod|pre-prod)/i, Construction],
|
||||
[/(test|testing|qa)/i, FlaskConical],
|
||||
[/(prod|production)/i, Cloud],
|
||||
[/(live)/i, Radio],
|
||||
[/(selfhosted|self-hosted|on-prem|onprem)/i, Server],
|
||||
[/(school|university|edu|study|student)/i, GraduationCap],
|
||||
[/(client|customer)/i, Building],
|
||||
[/(family)/i, Users],
|
||||
[/(gaming|game)/i, Gamepad2],
|
||||
[/(guest)/i, UserPlus],
|
||||
];
|
||||
|
||||
export const pickProfileIcon = (name: string | undefined): LucideIcon | null => {
|
||||
if (!name) return null;
|
||||
for (const [pattern, Icon] of ICON_MAP) {
|
||||
if (pattern.test(name)) return Icon;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
name?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ProfileAvatar = forwardRef<HTMLButtonElement, Props>(function ProfileAvatar(
|
||||
{ name = "", size = 28, className, type = "button", ...props },
|
||||
ref,
|
||||
) {
|
||||
const Icon = pickProfileIcon(name) ?? UserCircle;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
"inline-grid place-items-center rounded-full bg-nb-gray-900 p-0 text-center",
|
||||
"cursor-default outline-none",
|
||||
"transition-colors duration-150 hover:bg-nb-gray-850",
|
||||
"data-[state=open]:bg-nb-gray-850",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={Math.round(size * 0.4)} className={"text-nb-gray-200"} />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
263
client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx
Normal file
263
client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Dialog from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
|
||||
import {
|
||||
CLOUD_MANAGEMENT_URL,
|
||||
ManagementMode,
|
||||
checkManagementUrlReachable,
|
||||
isValidManagementUrl,
|
||||
normalizeManagementUrl,
|
||||
} from "@/hooks/useManagementUrl";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
|
||||
export type ProfileFormInitial = {
|
||||
name: string;
|
||||
managementUrl: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (name: string, managementUrl: string) => void | Promise<void>;
|
||||
initial?: ProfileFormInitial;
|
||||
};
|
||||
|
||||
const MAX_PROFILE_NAME_LEN = 128;
|
||||
|
||||
export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { mdm } = useRestrictions();
|
||||
const managedManagementUrl = mdm.managementURL;
|
||||
const nameId = useId();
|
||||
const urlId = useId();
|
||||
const isEdit = !!initial;
|
||||
const initialModeFromUrl = (u: string): ManagementMode =>
|
||||
u && u !== CLOUD_MANAGEMENT_URL ? ManagementMode.SelfHosted : ManagementMode.Cloud;
|
||||
const initialSelfHostedUrl = (u: string): string => (u && u !== CLOUD_MANAGEMENT_URL ? u : "");
|
||||
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [mode, setMode] = useState<ManagementMode>(
|
||||
initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud,
|
||||
);
|
||||
const [url, setUrl] = useState(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initial?.name ?? "");
|
||||
setMode(initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud);
|
||||
setUrl(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
|
||||
setNameError(null);
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
setChecking(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initial?.name, initial?.managementUrl]);
|
||||
|
||||
const initialModeRef = useRef<ManagementMode>(ManagementMode.Cloud);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
initialModeRef.current = mode;
|
||||
const id = globalThis.setTimeout(() => {
|
||||
nameRef.current?.focus();
|
||||
nameRef.current?.select();
|
||||
}, 0);
|
||||
return () => globalThis.clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// When the user toggles to Self-hosted inside the dialog (not on initial
|
||||
// open), move focus to the URL input so they can start typing immediately.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === initialModeRef.current) return;
|
||||
if (mode !== ManagementMode.SelfHosted) return;
|
||||
urlRef.current?.focus();
|
||||
}, [open, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
const resolveTargetUrl = (): { url: string; needsReachCheck: boolean } | null => {
|
||||
if (managedManagementUrl) {
|
||||
return { url: managedManagementUrl, needsReachCheck: false };
|
||||
}
|
||||
if (mode === ManagementMode.Cloud) {
|
||||
return { url: CLOUD_MANAGEMENT_URL, needsReachCheck: false };
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || !isValidManagementUrl(trimmed)) {
|
||||
setUrlError(t("settings.general.management.urlError"));
|
||||
urlRef.current?.focus();
|
||||
return null;
|
||||
}
|
||||
const target = normalizeManagementUrl(trimmed);
|
||||
|
||||
const unchanged = target === initial?.managementUrl;
|
||||
return { url: target, needsReachCheck: !unchanged };
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (checking) return;
|
||||
|
||||
const sanitized = name.trim();
|
||||
if (sanitized.length === 0) {
|
||||
setNameError(t("profile.dialog.required"));
|
||||
nameRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = resolveTargetUrl();
|
||||
if (!target) return;
|
||||
|
||||
if (target.needsReachCheck) {
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(target.url);
|
||||
setChecking(false);
|
||||
if (!reachable && !unreachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await onSubmit(sanitized, target.url);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (nameError) setNameError(null);
|
||||
};
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
const showUrlSyntaxError =
|
||||
mode === ManagementMode.SelfHosted &&
|
||||
trimmedUrl !== "" &&
|
||||
!isValidManagementUrl(trimmedUrl);
|
||||
const urlInputError = showUrlSyntaxError
|
||||
? t("settings.general.management.urlError")
|
||||
: (urlError ?? undefined);
|
||||
const urlInputWarning =
|
||||
!urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content
|
||||
maxWidthClass={"max-w-md"}
|
||||
showClose={false}
|
||||
className={"py-7"}
|
||||
srTitle={isEdit ? t("profile.edit.title") : t("profile.dialog.title")}
|
||||
srDescription={t("profile.dialog.description")}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
// Focus + select-all so editing an existing name is one
|
||||
// keystroke away from overwriting it.
|
||||
nameRef.current?.focus();
|
||||
nameRef.current?.select();
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={"flex flex-col gap-6 px-7"}>
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<div className={"pl-1"}>
|
||||
<Label htmlFor={nameId} className={"mb-0.5"}>
|
||||
{t("profile.dialog.nameLabel")}
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("profile.dialog.description")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
id={nameId}
|
||||
ref={nameRef}
|
||||
autoFocus
|
||||
placeholder={t("profile.dialog.placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
error={nameError ?? undefined}
|
||||
maxLength={MAX_PROFILE_NAME_LEN}
|
||||
spellCheck={false}
|
||||
autoComplete={"off"}
|
||||
autoCapitalize={"off"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!managedManagementUrl && (
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<div className={"pl-1"}>
|
||||
<Label as={"div"} className={"mb-0.5"}>
|
||||
{t("settings.general.management.label")}
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("profile.dialog.managementHelp")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"flex flex-col gap-3"}>
|
||||
<ManagementServerSwitch
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
fullWidth
|
||||
/>
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<Input
|
||||
id={urlId}
|
||||
ref={urlRef}
|
||||
aria-label={t("settings.general.management.label")}
|
||||
placeholder={t(
|
||||
"settings.general.management.urlPlaceholder",
|
||||
)}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
error={urlInputError}
|
||||
warning={urlInputWarning}
|
||||
spellCheck={false}
|
||||
autoComplete={"off"}
|
||||
autoCorrect={"off"}
|
||||
autoCapitalize={"off"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogActions className={"flex-row items-center justify-end gap-2.5 pt-2"}>
|
||||
<Button
|
||||
type={"button"}
|
||||
variant={"secondary"}
|
||||
size={"sm"}
|
||||
disabled={checking}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type={"submit"}
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
loading={checking}
|
||||
>
|
||||
{isEdit ? t("profile.edit.submit") : t("profile.dialog.submit")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
293
client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx
Normal file
293
client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Check, ChevronDown, Settings2, UserCircle } from "lucide-react";
|
||||
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
};
|
||||
|
||||
const MANAGE_VALUE = "__manage_profiles__";
|
||||
|
||||
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (open) return;
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedProfiles = [...profiles].sort((a, b) => {
|
||||
if (a.id === activeProfileId) return -1;
|
||||
if (b.id === activeProfileId) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: title,
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setOpen(false);
|
||||
if (id === activeProfileId) return;
|
||||
void guarded(t("profile.error.switchTitle"), () => switchProfile(id));
|
||||
};
|
||||
|
||||
const handleManage = () => {
|
||||
setOpen(false);
|
||||
onManageProfiles?.();
|
||||
};
|
||||
|
||||
if (!loaded) return <ProfileTriggerSkeleton />;
|
||||
|
||||
const hasProfile = !!activeProfileId;
|
||||
const activeFromList = profiles.find((p) => p.id === activeProfileId)?.name;
|
||||
const displayName = hasProfile
|
||||
? (activeFromList ?? activeProfile)
|
||||
: t("profile.selector.noProfile");
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"} disabled={!hasProfile}>
|
||||
<ProfileTriggerButton
|
||||
name={displayName}
|
||||
disabled={!hasProfile}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align={"center"}
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
listRef.current?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:origin-top data-[side=top]:origin-bottom",
|
||||
"data-[side=left]:origin-right data-[side=right]:origin-left",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
shouldFilter={false}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={"outline-none focus:outline-none focus-visible:outline-none"}
|
||||
>
|
||||
<Command.List
|
||||
ref={listRef}
|
||||
aria-label={t("header.profile.switch")}
|
||||
className={"outline-none focus:outline-none focus-visible:outline-none"}
|
||||
>
|
||||
{sortedProfiles.length > 0 && (
|
||||
<>
|
||||
<ScrollArea.Root
|
||||
type={"auto"}
|
||||
className={"-mx-1 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"max-h-60 px-1"}>
|
||||
{sortedProfiles.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isActive={profile.id === activeProfileId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
<div className={"-mx-1 h-px bg-nb-gray-910"} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={"pt-1"}>
|
||||
<Command.Item
|
||||
value={MANAGE_VALUE}
|
||||
onSelect={handleManage}
|
||||
disabled={!onManageProfiles}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5",
|
||||
"cursor-default rounded-md text-sm outline-none",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
||||
)}
|
||||
>
|
||||
<Settings2
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{t("profile.dropdown.manageProfiles")}
|
||||
</span>
|
||||
</Command.Item>
|
||||
</div>
|
||||
</Command.List>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileTriggerSkeleton = () => (
|
||||
<div
|
||||
role={"status"}
|
||||
aria-busy={"true"}
|
||||
aria-live={"polite"}
|
||||
className={"wails-no-draggable flex h-10 select-none items-center gap-2 rounded-lg px-3"}
|
||||
>
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={"size-4 shrink-0 animate-pulse rounded-full bg-nb-gray-900"}
|
||||
/>
|
||||
<div aria-hidden={"true"} className={"h-4 w-24 animate-pulse rounded bg-nb-gray-900"} />
|
||||
</div>
|
||||
);
|
||||
|
||||
type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
|
||||
function ProfileTriggerButton({ name, className, disabled, ...props }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const isFocusVisible = useFocusVisible();
|
||||
const Icon = pickProfileIcon(name) ?? UserCircle;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={"button"}
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={t("header.profile.switch")}
|
||||
aria-haspopup={"listbox"}
|
||||
className={cn(
|
||||
"wails-no-draggable flex h-10 cursor-default select-none items-center gap-2 rounded-lg px-3 outline-none",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900",
|
||||
"data-[state=open]:bg-nb-gray-900",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"wails-no-draggable shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
<span className={"wails-no-draggable max-w-[140px] truncate text-sm font-medium"}>
|
||||
{name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={"wails-no-draggable shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type ProfileRowProps = {
|
||||
profile: Profile;
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
||||
const showEmail = !!profile.email;
|
||||
return (
|
||||
<Command.Item
|
||||
value={profile.id}
|
||||
onSelect={() => onSelect(profile.id)}
|
||||
className={cn(
|
||||
"flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1",
|
||||
"cursor-default rounded-md text-sm outline-none",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
showEmail ? "items-start" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
|
||||
<span className={"truncate"}>{profile.name}</span>
|
||||
{showEmail && <TruncatedEmail email={profile.email} />}
|
||||
</div>
|
||||
{isActive && (
|
||||
<Check
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")}
|
||||
/>
|
||||
)}
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const TruncatedEmail = ({ email }: { email: string }) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [email]);
|
||||
|
||||
const span = (
|
||||
<span ref={ref} className={"mt-0.5 max-w-[180px] truncate text-xs text-nb-gray-300"}>
|
||||
{email}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return <Tooltip content={email}>{span}</Tooltip>;
|
||||
};
|
||||
679
client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
Normal file
679
client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
import { type KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CircleMinus,
|
||||
LogIn,
|
||||
MoreVertical,
|
||||
PencilLine,
|
||||
PlusCircle,
|
||||
Trash2,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import HelpText from "@/components/typography/HelpText";
|
||||
import {
|
||||
ProfileCreationModal,
|
||||
type ProfileFormInitial,
|
||||
} from "@/modules/profiles/ProfileCreationModal";
|
||||
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { useConfirm } from "@/contexts/DialogContext";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import { SetConfigParams } from "@bindings/services/models.js";
|
||||
import { isNetbirdCloud } from "@/hooks/useManagementUrl.ts";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { reconcileOrder } from "@/lib/sorting";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
export function ProfilesTab() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
profiles,
|
||||
activeProfileId,
|
||||
loaded,
|
||||
username,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
logoutProfile,
|
||||
} = useProfile();
|
||||
|
||||
const confirm = useConfirm();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<{
|
||||
profile: Profile;
|
||||
initial: ProfileFormInitial;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Order is held stable so switching only flips the badge, never reorders rows
|
||||
// (else the clicked row jumps to the top under the cursor).
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const { order, items } = reconcileOrder(
|
||||
orderRef.current,
|
||||
profiles,
|
||||
(p) => p.id,
|
||||
(a, b) => {
|
||||
if (a.id === activeProfileId) return -1;
|
||||
if (b.id === activeProfileId) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
},
|
||||
);
|
||||
orderRef.current = order;
|
||||
return items;
|
||||
}, [profiles, activeProfileId]);
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: title,
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitch = async (id: string, name: string) => {
|
||||
const ok = await confirm({
|
||||
title: t("profile.switch.title", { name }),
|
||||
description: t("profile.switch.message", { name }),
|
||||
confirmLabel: t("profile.switch.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
|
||||
};
|
||||
|
||||
const handleDeregister = async (id: string, name: string) => {
|
||||
const ok = await confirm({
|
||||
title: t("profile.deregister.title", { name }),
|
||||
description: t("profile.deregister.message", { name }),
|
||||
confirmLabel: t("profile.deregister.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(id));
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (name === DEFAULT_PROFILE) return;
|
||||
const ok = await confirm({
|
||||
title: t("profile.delete.title", { name }),
|
||||
description: t("profile.delete.message", { name }),
|
||||
confirmLabel: t("common.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id));
|
||||
};
|
||||
|
||||
const handleCreate = async (name: string, managementUrl: string) => {
|
||||
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
||||
const id = await addProfile(name);
|
||||
// SetConfig is keyed by the new profile's ID, so it writes the
|
||||
// not-yet-active profile. Write before switching so any reconnect
|
||||
// targets the right deployment.
|
||||
if (!isNetbirdCloud(managementUrl)) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({ profileName: id, username, managementUrl }),
|
||||
);
|
||||
}
|
||||
await switchProfile(id);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = async (id: string, name: string) => {
|
||||
await guarded(i18next.t("profile.error.editTitle"), async () => {
|
||||
const config = await SettingsSvc.GetConfig({ profileName: id, username });
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
if (!profile) return;
|
||||
setEditTarget({
|
||||
profile,
|
||||
initial: { name, managementUrl: config.managementUrl },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async (name: string, managementUrl: string) => {
|
||||
if (!editTarget) return;
|
||||
const { profile, initial } = editTarget;
|
||||
await guarded(i18next.t("profile.error.editTitle"), async () => {
|
||||
if (name !== initial.name) {
|
||||
await renameProfile(profile.id, name);
|
||||
}
|
||||
if (managementUrl !== initial.managementUrl) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({
|
||||
profileName: profile.id,
|
||||
username,
|
||||
managementUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionGroup title={t("settings.profiles.section.profiles")}>
|
||||
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-nb-gray-900 bg-nb-gray-930/60",
|
||||
)}
|
||||
>
|
||||
<ProfilesTable
|
||||
ordered={ordered}
|
||||
activeProfileId={activeProfileId}
|
||||
onSwitch={handleSwitch}
|
||||
onEdit={handleEdit}
|
||||
onDeregister={handleDeregister}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{loaded && ordered.length === 0 && (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center py-10 text-center"
|
||||
}
|
||||
>
|
||||
<UserCircle
|
||||
size={28}
|
||||
aria-hidden={"true"}
|
||||
className={"mb-2 text-nb-gray-500"}
|
||||
/>
|
||||
<p className={"text-sm font-semibold text-nb-gray-200"}>
|
||||
{t("settings.profiles.emptyTitle")}
|
||||
</p>
|
||||
<p className={"mt-1 max-w-sm text-balance text-xs text-nb-gray-400"}>
|
||||
{t("settings.profiles.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SettingsBottomBar>
|
||||
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
|
||||
<PlusCircle size={14} aria-hidden={"true"} />
|
||||
{t("settings.profiles.addProfile")}
|
||||
</Button>
|
||||
</SettingsBottomBar>
|
||||
</SectionGroup>
|
||||
|
||||
<ProfileCreationModal
|
||||
open={newOpen}
|
||||
onOpenChange={setNewOpen}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
|
||||
<ProfileCreationModal
|
||||
open={editTarget !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setEditTarget(null);
|
||||
}}
|
||||
initial={editTarget?.initial}
|
||||
onSubmit={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfilesTableProps = {
|
||||
ordered: Profile[];
|
||||
activeProfileId: string | undefined;
|
||||
onSwitch: (id: string, name: string) => void;
|
||||
onEdit: (id: string, name: string) => void;
|
||||
onDeregister: (id: string, name: string) => void;
|
||||
onDelete: (id: string, name: string) => void;
|
||||
};
|
||||
|
||||
const ProfilesTable = ({
|
||||
ordered,
|
||||
activeProfileId,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: ProfilesTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map());
|
||||
|
||||
const focusRow = (index: number) => {
|
||||
if (index < 0 || index >= ordered.length) return;
|
||||
setFocusedIndex(index);
|
||||
const el = rowRefs.current.get(ordered[index].id);
|
||||
el?.focus();
|
||||
};
|
||||
|
||||
const actionButtonsIn = (row: HTMLTableRowElement | undefined) =>
|
||||
Array.from(
|
||||
row?.querySelectorAll<HTMLButtonElement>(
|
||||
"button:not([aria-hidden='true']):not([aria-disabled='true'])",
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
const handleRowKey = (e: KeyboardEvent<HTMLTableRowElement>, index: number): boolean => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
focusRow(Math.min(index + 1, ordered.length - 1));
|
||||
return true;
|
||||
case "ArrowUp":
|
||||
focusRow(Math.max(index - 1, 0));
|
||||
return true;
|
||||
case "Home":
|
||||
focusRow(0);
|
||||
return true;
|
||||
case "End":
|
||||
focusRow(ordered.length - 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleButtonKey = (
|
||||
e: KeyboardEvent<HTMLTableRowElement>,
|
||||
index: number,
|
||||
row: HTMLTableRowElement,
|
||||
): boolean => {
|
||||
const buttons = actionButtonsIn(row);
|
||||
const current = buttons.indexOf(e.target as HTMLButtonElement);
|
||||
if (current === -1) return false;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
focusRow(Math.min(index + 1, ordered.length - 1));
|
||||
return true;
|
||||
case "ArrowUp":
|
||||
focusRow(Math.max(index - 1, 0));
|
||||
return true;
|
||||
case "Escape":
|
||||
row.focus();
|
||||
return true;
|
||||
case "Tab":
|
||||
// At the last button: jump to the next row instead of exiting the table.
|
||||
// At the first button with Shift+Tab: jump back to the row.
|
||||
if (!e.shiftKey && current === buttons.length - 1 && index < ordered.length - 1) {
|
||||
focusRow(index + 1);
|
||||
return true;
|
||||
}
|
||||
if (e.shiftKey && current === 0) {
|
||||
row.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<HTMLTableRowElement>, index: number) => {
|
||||
const row = rowRefs.current.get(ordered[index].id);
|
||||
if (!row) return;
|
||||
const onRow = e.target === row;
|
||||
const handled = onRow ? handleRowKey(e, index) : handleButtonKey(e, index, row);
|
||||
if (handled) e.preventDefault();
|
||||
};
|
||||
|
||||
const safeFocusedIndex = Math.min(focusedIndex, Math.max(0, ordered.length - 1));
|
||||
|
||||
return (
|
||||
<table
|
||||
aria-label={t("settings.profiles.section.profiles")}
|
||||
className={"w-full border-separate border-spacing-0 text-sm"}
|
||||
>
|
||||
<tbody className={"flex flex-col"}>
|
||||
{ordered.map((profile, index) => (
|
||||
<ProfileRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isActive={profile.id === activeProfileId}
|
||||
isFocused={index === safeFocusedIndex}
|
||||
isFirst={index === 0}
|
||||
isLast={index === ordered.length - 1}
|
||||
rowRef={(el) => {
|
||||
if (el) rowRefs.current.set(profile.id, el);
|
||||
else rowRefs.current.delete(profile.id);
|
||||
}}
|
||||
onKeyDown={(e) => handleRowKeyDown(e, index)}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
onSwitch={() => onSwitch(profile.id, profile.name)}
|
||||
onEdit={() => onEdit(profile.id, profile.name)}
|
||||
onDeregister={() => onDeregister(profile.id, profile.name)}
|
||||
onDelete={() => onDelete(profile.id, profile.name)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
type ProfileRowProps = {
|
||||
profile: Profile;
|
||||
isActive: boolean;
|
||||
isFocused: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
rowRef: (el: HTMLTableRowElement | null) => void;
|
||||
onKeyDown: (e: KeyboardEvent<HTMLTableRowElement>) => void;
|
||||
onFocus: () => void;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const ProfileRow = ({
|
||||
profile,
|
||||
isActive,
|
||||
isFocused,
|
||||
isFirst,
|
||||
isLast,
|
||||
rowRef,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: ProfileRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
|
||||
const showEmail = !!profile.email;
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={rowRef}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
aria-label={profile.name}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-4 py-2.5",
|
||||
"border-b border-nb-gray-910 last:border-b-0",
|
||||
"outline-none",
|
||||
isFirst && "rounded-t-xl",
|
||||
isLast && "rounded-b-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 gap-2 leading-tight",
|
||||
showEmail ? "items-start" : "items-center",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={15}
|
||||
aria-hidden={"true"}
|
||||
className={cn("shrink-0 text-nb-gray-200", showEmail ? "mt-0.5" : "")}
|
||||
/>
|
||||
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
|
||||
<div className={"flex min-w-0 items-center gap-2"}>
|
||||
<span
|
||||
className={
|
||||
"cursor-text select-text truncate font-medium text-nb-gray-100"
|
||||
}
|
||||
>
|
||||
{profile.name}
|
||||
</span>
|
||||
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
|
||||
</div>
|
||||
{showEmail && <TruncatedEmail email={profile.email} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className={"shrink-0 text-right"}>
|
||||
<RowActions
|
||||
canSwitch={!isActive}
|
||||
canDeregister={!!profile.email}
|
||||
isDefault={profile.name === DEFAULT_PROFILE}
|
||||
isActive={isActive}
|
||||
rowFocused={isFocused}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDeregister={onDeregister}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const TruncatedEmail = ({ email }: { email: string }) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [email]);
|
||||
|
||||
const span = (
|
||||
<span
|
||||
ref={ref}
|
||||
className={"mt-0.5 cursor-text select-text truncate text-xs text-nb-gray-300"}
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return <Tooltip content={email}>{span}</Tooltip>;
|
||||
};
|
||||
|
||||
type RowActionsProps = {
|
||||
canSwitch: boolean;
|
||||
canDeregister: boolean;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
rowFocused: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const RowActions = ({
|
||||
canSwitch,
|
||||
canDeregister,
|
||||
isDefault,
|
||||
isActive,
|
||||
rowFocused,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: RowActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const deleteDisabled = isDefault || isActive;
|
||||
let deleteDisabledReason: string | null = null;
|
||||
if (isDefault) deleteDisabledReason = t("profile.delete.disabledDefault");
|
||||
else if (isActive) deleteDisabledReason = t("profile.delete.disabledActive");
|
||||
return (
|
||||
<div className={"inline-flex items-center gap-1"}>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.switchTo")}
|
||||
icon={LogIn}
|
||||
onClick={onSwitch}
|
||||
hidden={!canSwitch}
|
||||
tabbable={rowFocused}
|
||||
/>
|
||||
<RowMoreMenu
|
||||
canDeregister={canDeregister}
|
||||
deleteDisabled={deleteDisabled}
|
||||
deleteDisabledReason={deleteDisabledReason}
|
||||
rowFocused={rowFocused}
|
||||
onEdit={onEdit}
|
||||
onDeregister={onDeregister}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type RowMoreMenuProps = {
|
||||
canDeregister: boolean;
|
||||
deleteDisabled: boolean;
|
||||
deleteDisabledReason: string | null;
|
||||
rowFocused: boolean;
|
||||
onEdit: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const RowMoreMenu = ({
|
||||
canDeregister,
|
||||
deleteDisabled,
|
||||
deleteDisabledReason,
|
||||
rowFocused,
|
||||
onEdit,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: RowMoreMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const moreLabel = t("profile.selector.moreOptions");
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={moreLabel}
|
||||
tabIndex={rowFocused ? 0 : -1}
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
<MoreVertical size={16} aria-hidden={"true"} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} sideOffset={4} className={"min-w-36 select-none"}>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<div className={"flex w-full items-center gap-2"}>
|
||||
<PencilLine size={14} aria-hidden={"true"} />
|
||||
<span className={"flex-1"}>{t("profile.selector.edit")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{canDeregister && (
|
||||
<DropdownMenuItem onClick={onDeregister}>
|
||||
<div className={"flex w-full items-center gap-2"}>
|
||||
<CircleMinus size={14} aria-hidden={"true"} />
|
||||
<span className={"flex-1"}>{t("profile.selector.deregister")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DeleteMenuItem
|
||||
disabled={deleteDisabled}
|
||||
disabledReason={deleteDisabledReason}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
type DeleteMenuItemProps = {
|
||||
disabled: boolean;
|
||||
disabledReason: string | null;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const item = (
|
||||
<DropdownMenuItem
|
||||
disabled={disabled}
|
||||
onClick={disabled ? undefined : onDelete}
|
||||
className={cn(!disabled && "text-red-500 hover:!text-red-500 focus:text-red-500")}
|
||||
>
|
||||
<div className={"flex w-full items-center gap-2"}>
|
||||
<Trash2 size={14} aria-hidden={"true"} />
|
||||
<span className={"flex-1"}>{t("profile.selector.delete")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
if (!disabled || !disabledReason) return item;
|
||||
return (
|
||||
<Tooltip
|
||||
content={<span className={"block max-w-[260px] leading-snug"}>{disabledReason}</span>}
|
||||
side={"left"}
|
||||
>
|
||||
<span className={"block"}>{item}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
type ActionIconButtonProps = {
|
||||
label: string;
|
||||
icon: typeof CircleMinus;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger";
|
||||
/** Occupies space but invisible and non-interactive (preserves row layout). */
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
tabbable?: boolean;
|
||||
};
|
||||
|
||||
const ActionIconButton = ({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
variant = "default",
|
||||
hidden = false,
|
||||
disabled = false,
|
||||
tabbable = true,
|
||||
}: ActionIconButtonProps) => {
|
||||
const button = (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
aria-label={label}
|
||||
aria-hidden={hidden || undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
tabIndex={hidden || !tabbable ? -1 : 0}
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
variant === "danger"
|
||||
? "text-nb-gray-400 hover:bg-red-500/10 hover:text-red-500"
|
||||
: "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
hidden && "pointer-events-none opacity-0",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-40 hover:!bg-transparent hover:!text-nb-gray-400",
|
||||
)}
|
||||
>
|
||||
<Icon size={16} aria-hidden={"true"} />
|
||||
</button>
|
||||
);
|
||||
if (hidden) return button;
|
||||
return (
|
||||
<Tooltip
|
||||
content={<span className={"block max-w-[260px] leading-snug"}>{label}</span>}
|
||||
side={"top"}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { AlertCircleIcon, ClockIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
|
||||
const DEFAULT_SECONDS = 360;
|
||||
const WINDOW_WIDTH = 360;
|
||||
const SOON_THRESHOLD_SECONDS = 60 * 60;
|
||||
|
||||
export default function SessionExpirationDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [params] = useSearchParams();
|
||||
const initialSeconds = useMemo(() => {
|
||||
const raw = params.get("seconds");
|
||||
if (!raw) return DEFAULT_SECONDS;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS;
|
||||
}, [params]);
|
||||
|
||||
const [remaining, setRemaining] = useState(initialSeconds);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(busy);
|
||||
busyRef.current = busy;
|
||||
const expired = remaining <= 0;
|
||||
const expiredRef = useRef(expired);
|
||||
expiredRef.current = expired;
|
||||
const soon = remaining <= SOON_THRESHOLD_SECONDS;
|
||||
const activeTitle = soon ? t("sessionExpiration.title") : t("sessionExpiration.titleLater");
|
||||
const activeDescription = soon
|
||||
? t("sessionExpiration.description")
|
||||
: t("sessionExpiration.descriptionLater");
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(initialSeconds);
|
||||
}, [initialSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = globalThis.setInterval(() => {
|
||||
setRemaining((s) => (s <= 1 ? 0 : s - 1));
|
||||
}, 1000);
|
||||
return () => globalThis.clearInterval(id);
|
||||
}, [initialSeconds]);
|
||||
|
||||
// Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state).
|
||||
useEffect(() => {
|
||||
const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => {
|
||||
if (busyRef.current || expiredRef.current) return;
|
||||
if (ev?.data?.status === "Connected") {
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stay = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
|
||||
let offCancel: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
const start = await Session.RequestExtend({ hint: "" });
|
||||
const uri = start.verificationUriComplete || start.verificationUri;
|
||||
|
||||
// The popup opens the URL and (Go-side) hides this window, restoring it on close.
|
||||
if (uri) {
|
||||
try {
|
||||
await WindowManager.OpenBrowserLogin(uri);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPromise = new Promise<void>((resolve) => {
|
||||
offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const waitPromise = Session.WaitExtend({
|
||||
deviceCode: start.deviceCode,
|
||||
userCode: start.userCode,
|
||||
});
|
||||
|
||||
const outcome = await Promise.race([
|
||||
waitPromise.then((r) => ({ kind: "done" as const, result: r })),
|
||||
cancelPromise.then(() => ({ kind: "cancel" as const })),
|
||||
]);
|
||||
|
||||
if (outcome.kind === "cancel") {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Another surface owns this flow; keep the dialog open to retry.
|
||||
if (outcome.result.preempted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close before the popup so the restore can't flash this window back.
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.extendFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
offCancel?.();
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const username = await ProfilesSvc.Username();
|
||||
const active = await ProfilesSvc.GetActive();
|
||||
await Connection.Logout({
|
||||
profileName: active.id || "default",
|
||||
username,
|
||||
});
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.logoutFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-session-expiration-title"}>
|
||||
<SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading id={"nb-session-expiration-title"}>
|
||||
{expired ? t("sessionExpiration.expired") : activeTitle}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{expired ? t("sessionExpiration.expiredDescription") : activeDescription}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
{!expired && (
|
||||
<div
|
||||
className={
|
||||
"font-mono text-2xl font-semibold tabular-nums tracking-wider text-nb-gray-50"
|
||||
}
|
||||
aria-live={"polite"}
|
||||
>
|
||||
{formatRemaining(remaining)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={stay}
|
||||
disabled={busy}
|
||||
>
|
||||
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={expired ? close : logout}
|
||||
disabled={busy}
|
||||
>
|
||||
{expired ? t("sessionExpiration.close") : t("sessionExpiration.logout")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
169
client/ui/frontend/src/modules/settings/SettingsAbout.tsx
Normal file
169
client/ui/frontend/src/modules/settings/SettingsAbout.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react";
|
||||
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
|
||||
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
|
||||
<path
|
||||
d={
|
||||
"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
|
||||
<path
|
||||
d={
|
||||
"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
|
||||
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => {
|
||||
window.open(url, "_blank");
|
||||
});
|
||||
}
|
||||
|
||||
export function SettingsAbout() {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { guiVersion } = useSettings();
|
||||
const daemonVersion = status?.daemonVersion ?? "—";
|
||||
|
||||
const handleVersionClick = useAccentTrigger();
|
||||
|
||||
const COMMUNITY_LINKS: {
|
||||
label: string;
|
||||
url: string;
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
iconClassName?: string;
|
||||
}[] = [
|
||||
{
|
||||
label: t("settings.about.community.github"),
|
||||
url: "https://github.com/netbirdio/netbird",
|
||||
Icon: GithubIcon,
|
||||
iconClassName: "h-3 w-3",
|
||||
},
|
||||
{
|
||||
label: t("settings.about.community.slack"),
|
||||
url: "https://docs.netbird.io/slack-url",
|
||||
Icon: SlackIcon,
|
||||
iconClassName: "h-3 w-3",
|
||||
},
|
||||
{
|
||||
label: t("settings.about.community.forum"),
|
||||
url: "https://forum.netbird.io",
|
||||
Icon: MessagesSquare,
|
||||
},
|
||||
{
|
||||
label: t("settings.about.community.documentation"),
|
||||
url: "https://docs.netbird.io",
|
||||
Icon: BookOpen,
|
||||
},
|
||||
{
|
||||
label: t("settings.about.community.feedback"),
|
||||
url: "https://forms.gle/TeLw2zrXEdw6RcQ36",
|
||||
Icon: MessageSquareText,
|
||||
},
|
||||
];
|
||||
|
||||
const LEGAL_LINKS: { label: string; url: string }[] = [
|
||||
{ label: t("settings.about.links.imprint"), url: "https://netbird.io/imprint" },
|
||||
{ label: t("settings.about.links.privacy"), url: "https://netbird.io/privacy" },
|
||||
{ label: t("settings.about.links.cla"), url: "https://netbird.io/cla" },
|
||||
{ label: t("settings.about.links.terms"), url: "https://netbird.io/terms" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"mx-auto flex min-h-[calc(100vh-12rem)] max-w-2xl flex-col items-center justify-center gap-4"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={handleVersionClick}
|
||||
className={
|
||||
"cursor-text select-text bg-transparent text-sm font-semibold text-nb-gray-100 outline-none"
|
||||
}
|
||||
>
|
||||
{daemonVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.clientName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.client", { version: daemonVersion })
|
||||
)}
|
||||
</button>
|
||||
<p className={"cursor-text select-text text-sm font-medium text-nb-gray-250"}>
|
||||
{guiVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.guiName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.gui", { version: guiVersion })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UpdateVersionCard />
|
||||
|
||||
<p className={"mt-2 text-center text-sm text-nb-gray-300"}>
|
||||
{t("settings.about.copyright", { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
<div
|
||||
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
|
||||
>
|
||||
{COMMUNITY_LINKS.map(({ label, url, Icon, iconClassName }) => (
|
||||
<button
|
||||
key={url}
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
onClick={() => openUrl(url)}
|
||||
className={
|
||||
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
}
|
||||
>
|
||||
<Icon aria-hidden={"true"} className={iconClassName ?? "h-3.5 w-3.5"} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
|
||||
>
|
||||
{LEGAL_LINKS.map((link) => (
|
||||
<button
|
||||
key={link.url}
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
onClick={() => openUrl(link.url)}
|
||||
className={
|
||||
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
client/ui/frontend/src/modules/settings/SettingsAccent.tsx
Normal file
116
client/ui/frontend/src/modules/settings/SettingsAccent.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
export function useAccentTrigger() {
|
||||
const clicksRef = useRef(0);
|
||||
const lastClickRef = useRef(0);
|
||||
|
||||
return useCallback(() => {
|
||||
const now = performance.now();
|
||||
if (now - lastClickRef.current > 400) {
|
||||
clicksRef.current = 0;
|
||||
}
|
||||
lastClickRef.current = now;
|
||||
clicksRef.current += 1;
|
||||
if (clicksRef.current >= 10) {
|
||||
clicksRef.current = 0;
|
||||
triggerAccent();
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
function triggerAccent() {
|
||||
if (document.getElementById("nb-accent-root")) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.id = "nb-accent-root";
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const cleanup = () => {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
};
|
||||
|
||||
root.render(<Accent onDone={cleanup} />);
|
||||
}
|
||||
|
||||
function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth * dpr;
|
||||
canvas.height = window.innerHeight * dpr;
|
||||
canvas.style.width = `${window.innerWidth}px`;
|
||||
canvas.style.height = `${window.innerHeight}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const chars = "TEAMNETBIRD";
|
||||
const fontSize = 16;
|
||||
const columns = Math.floor(window.innerWidth / fontSize);
|
||||
const drops = Array.from({ length: columns }, () => Math.random() * -50);
|
||||
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
const draw = (t: number) => {
|
||||
if (t - last > 50) {
|
||||
last = t;
|
||||
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.12)";
|
||||
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.font = `${fontSize}px ui-monospace, monospace`;
|
||||
ctx.fillStyle = "#f68330";
|
||||
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
const ch = chars[Math.floor(Math.random() * chars.length)];
|
||||
const y = drops[i] * fontSize;
|
||||
ctx.fillText(ch, i * fontSize, y);
|
||||
if (y > window.innerHeight && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
}
|
||||
drops[i]++;
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
raf = requestAnimationFrame(draw);
|
||||
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
setVisible(false);
|
||||
globalThis.setTimeout(onDone, 500);
|
||||
}, 9000);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
globalThis.clearTimeout(timeout);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [onDone]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none fixed inset-0 z-50 bg-black/5 transition-opacity duration-500 ${visible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<canvas ref={canvasRef} className={"block"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx
Normal file
179
client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { System } from "@wailsio/runtime";
|
||||
import Button from "@/components/buttons/Button";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
|
||||
// macOS daemon/CLI only accept utun<N> (Darwin parses digits as the utun unit); Linux caps at IFNAMSIZ-1 = 15 chars.
|
||||
const IS_MAC = System.IsMac();
|
||||
const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/;
|
||||
const INTERFACE_NAME_ERROR_KEY = IS_MAC
|
||||
? "settings.advanced.interfaceName.errorMac"
|
||||
: "settings.advanced.interfaceName.error";
|
||||
|
||||
// Port 0 lets the daemon pick a random free port.
|
||||
const PORT_MIN = 0;
|
||||
const PORT_MAX = 65535;
|
||||
|
||||
// Mirrors client/iface/iface.go MinMTU / MaxMTU.
|
||||
const MTU_MIN = 576;
|
||||
const MTU_MAX = 8192;
|
||||
|
||||
const PSK_MASK = "**********";
|
||||
|
||||
export function SettingsAdvanced() {
|
||||
const { t } = useTranslation();
|
||||
const { config, saveFields } = useSettings();
|
||||
const { mdm } = useRestrictions();
|
||||
|
||||
const initialPsk = config.preSharedKeySet ? PSK_MASK : "";
|
||||
|
||||
const [values, setValues] = useState({
|
||||
interfaceName: config.interfaceName,
|
||||
wireguardPort: config.wireguardPort,
|
||||
mtu: config.mtu,
|
||||
});
|
||||
|
||||
const [pskInputValue, setPskInputValue] = useState(initialPsk);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setValues({
|
||||
interfaceName: config.interfaceName,
|
||||
wireguardPort: config.wireguardPort,
|
||||
mtu: config.mtu,
|
||||
});
|
||||
setPskInputValue(config.preSharedKeySet ? PSK_MASK : "");
|
||||
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKeySet]);
|
||||
|
||||
const errors = useMemo(() => {
|
||||
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
|
||||
if (!INTERFACE_NAME_RE.test(values.interfaceName)) {
|
||||
out.interfaceName = t(INTERFACE_NAME_ERROR_KEY);
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(values.wireguardPort) ||
|
||||
values.wireguardPort < PORT_MIN ||
|
||||
values.wireguardPort > PORT_MAX
|
||||
) {
|
||||
out.wireguardPort = t("settings.advanced.port.error", {
|
||||
min: PORT_MIN,
|
||||
max: PORT_MAX,
|
||||
});
|
||||
}
|
||||
if (!Number.isInteger(values.mtu) || values.mtu < MTU_MIN || values.mtu > MTU_MAX) {
|
||||
out.mtu = t("settings.advanced.mtu.error", { min: MTU_MIN, max: MTU_MAX });
|
||||
}
|
||||
return out;
|
||||
}, [values.interfaceName, values.wireguardPort, values.mtu, t]);
|
||||
|
||||
const filteredErrors = mdm.wireguardPort ? { ...errors, wireguardPort: undefined } : errors;
|
||||
const hasErrors = Object.values(filteredErrors).some((v) => v !== undefined);
|
||||
const pskChanged = pskInputValue !== initialPsk;
|
||||
const hasChanges =
|
||||
values.interfaceName !== config.interfaceName ||
|
||||
(!mdm.wireguardPort && values.wireguardPort !== config.wireguardPort) ||
|
||||
values.mtu !== config.mtu ||
|
||||
(!mdm.preSharedKey && pskChanged);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || saving || hasErrors) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const partial: typeof values = { ...values };
|
||||
if (mdm.wireguardPort) partial.wireguardPort = config.wireguardPort;
|
||||
|
||||
const pskEdited = !mdm.preSharedKey && pskChanged && pskInputValue !== PSK_MASK;
|
||||
const pskOpts = pskEdited ? { preSharedKey: pskInputValue } : undefined;
|
||||
await saveFields(partial, pskOpts);
|
||||
if (pskEdited) setPskInputValue(pskInputValue === "" ? "" : PSK_MASK);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={t("settings.advanced.section.interface")}>
|
||||
<Input
|
||||
label={t("settings.advanced.interfaceName.label")}
|
||||
value={values.interfaceName}
|
||||
error={errors.interfaceName}
|
||||
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
|
||||
spellCheck={false}
|
||||
autoComplete={"off"}
|
||||
autoCorrect={"off"}
|
||||
autoCapitalize={"off"}
|
||||
/>
|
||||
<div className={mdm.wireguardPort ? "" : "grid grid-cols-2 gap-4"}>
|
||||
{!mdm.wireguardPort && (
|
||||
<div>
|
||||
<Input
|
||||
label={t("settings.advanced.port.label")}
|
||||
type={"number"}
|
||||
min={PORT_MIN}
|
||||
max={PORT_MAX}
|
||||
value={values.wireguardPort}
|
||||
error={errors.wireguardPort}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
wireguardPort: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<HelpText className={"mt-1.5"}>
|
||||
{t("settings.advanced.port.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label={t("settings.advanced.mtu.label")}
|
||||
type={"number"}
|
||||
min={MTU_MIN}
|
||||
max={MTU_MAX}
|
||||
value={values.mtu}
|
||||
error={errors.mtu}
|
||||
onChange={(e) => setValues((v) => ({ ...v, mtu: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
{!mdm.preSharedKey && (
|
||||
<SectionGroup title={t("settings.advanced.section.security")}>
|
||||
<div>
|
||||
<Label as={"div"}>{t("settings.advanced.psk.label")}</Label>
|
||||
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
|
||||
<Input
|
||||
type={"password"}
|
||||
showPasswordToggle={pskInputValue !== PSK_MASK}
|
||||
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
||||
value={pskInputValue}
|
||||
onChange={(e) => setPskInputValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete={"new-password"}
|
||||
autoCorrect={"off"}
|
||||
autoCapitalize={"off"}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
)}
|
||||
|
||||
<SettingsBottomBar>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
disabled={!hasChanges || saving || hasErrors}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("common.saveChanges")}
|
||||
</Button>
|
||||
</SettingsBottomBar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
client/ui/frontend/src/modules/settings/SettingsGeneral.tsx
Normal file
113
client/ui/frontend/src/modules/settings/SettingsGeneral.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { autostart, setAutostartEnabled } = useAutostartSetting();
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
|
||||
useManagementUrl();
|
||||
const { mdm, features } = useRestrictions();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const managementUrlId = useId();
|
||||
const prevMode = useRef(mode);
|
||||
useEffect(() => {
|
||||
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
prevMode.current = mode;
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={t("settings.general.section.general")}>
|
||||
<LanguagePicker />
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableNotifications}
|
||||
onChange={(v) => setField("disableNotifications", !v)}
|
||||
label={t("settings.general.notifications.label")}
|
||||
helpText={t("settings.general.notifications.help")}
|
||||
/>
|
||||
{!mdm.disableAutoConnect && !features.disableUpdateSettings && (
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableAutoConnect}
|
||||
onChange={(v) => setField("disableAutoConnect", !v)}
|
||||
label={t("settings.general.connectOnStartup.label")}
|
||||
helpText={t("settings.general.connectOnStartup.help")}
|
||||
/>
|
||||
)}
|
||||
{(autostart === null || autostart.supported) && (
|
||||
<FancyToggleSwitch
|
||||
value={autostart?.enabled ?? false}
|
||||
onChange={setAutostartEnabled}
|
||||
loading={autostart === null}
|
||||
label={t("settings.general.autostart.label")}
|
||||
helpText={t("settings.general.autostart.help")}
|
||||
/>
|
||||
)}
|
||||
</SectionGroup>
|
||||
|
||||
{!mdm.managementURL && !features.disableUpdateSettings && (
|
||||
<SectionGroup title={t("settings.general.section.connection")}>
|
||||
<div>
|
||||
<div className={"flex items-start gap-3"}>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<Label htmlFor={managementUrlId}>
|
||||
{t("settings.general.management.label")}
|
||||
</Label>
|
||||
<HelpText>{t("settings.general.management.help")}</HelpText>
|
||||
</div>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} />
|
||||
</div>
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<div className={"mt-2 flex items-start gap-3"}>
|
||||
<Input
|
||||
id={managementUrlId}
|
||||
ref={inputRef}
|
||||
value={displayUrl}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={t("settings.general.management.urlPlaceholder")}
|
||||
error={
|
||||
showError
|
||||
? t("settings.general.management.urlError")
|
||||
: undefined
|
||||
}
|
||||
warning={
|
||||
unreachable
|
||||
? t("settings.general.management.urlUnreachable")
|
||||
: undefined
|
||||
}
|
||||
spellCheck={false}
|
||||
autoComplete={"off"}
|
||||
autoCorrect={"off"}
|
||||
autoCapitalize={"off"}
|
||||
/>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
disabled={!canSave}
|
||||
loading={checking}
|
||||
onClick={() => save()}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@/components/Tooltip.tsx";
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx";
|
||||
import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import {
|
||||
BoltIcon,
|
||||
InfoIcon,
|
||||
LifeBuoyIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SquareTerminalIcon,
|
||||
UserCircleIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const SettingsNavigation = () => {
|
||||
const { t } = useTranslation();
|
||||
const { updateAvailable } = useClientVersion();
|
||||
const { mdm, features } = useRestrictions();
|
||||
const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings;
|
||||
|
||||
const aboutAdornment = updateAvailable ? (
|
||||
<Tooltip content={t("settings.tabs.updateAvailable")} side={"right"}>
|
||||
<UpdateBadge />
|
||||
</Tooltip>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className={"flex w-52 shrink-0 select-none flex-col items-center"}>
|
||||
<VerticalTabs.List aria-label={t("settings.nav.label")}>
|
||||
<VerticalTabs.Trigger
|
||||
value={"general"}
|
||||
icon={SlidersHorizontalIcon}
|
||||
title={t("settings.tabs.general")}
|
||||
/>
|
||||
{!features.disableUpdateSettings && (
|
||||
<>
|
||||
<VerticalTabs.Trigger
|
||||
value={"network"}
|
||||
icon={NetworkIcon}
|
||||
title={t("settings.tabs.network")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"security"}
|
||||
icon={ShieldIcon}
|
||||
title={t("settings.tabs.security")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!features.disableProfiles && (
|
||||
<VerticalTabs.Trigger
|
||||
value={"profiles"}
|
||||
icon={UserCircleIcon}
|
||||
title={t("settings.tabs.profiles")}
|
||||
/>
|
||||
)}
|
||||
{showSsh && (
|
||||
<VerticalTabs.Trigger
|
||||
value={"ssh"}
|
||||
icon={SquareTerminalIcon}
|
||||
title={t("settings.tabs.ssh")}
|
||||
/>
|
||||
)}
|
||||
{!features.disableUpdateSettings && (
|
||||
<VerticalTabs.Trigger
|
||||
value={"advanced"}
|
||||
icon={BoltIcon}
|
||||
title={t("settings.tabs.advanced")}
|
||||
/>
|
||||
)}
|
||||
<VerticalTabs.Trigger
|
||||
value={"troubleshooting"}
|
||||
icon={LifeBuoyIcon}
|
||||
title={t("settings.tabs.troubleshooting")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"about"}
|
||||
icon={InfoIcon}
|
||||
title={t("settings.tabs.about")}
|
||||
adornment={aboutAdornment}
|
||||
/>
|
||||
</VerticalTabs.List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user