mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-15 05:09:55 +00:00
Merge branch 'ui-refactor' into ui-refactor-ui
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
|
||||
10
client/ui/frontend/.prettierrc
Normal file
10
client/ui/frontend/.prettierrc
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Connection groups the daemon RPCs that drive login / connect / disconnect.
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function Down(): $CancellablePromise<void> {
|
||||
return $Call.ByID(70044537);
|
||||
}
|
||||
|
||||
export function Login(p: $models.LoginParams): $CancellablePromise<$models.LoginResult> {
|
||||
return $Call.ByID(252661358, p).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function Logout(p: $models.LogoutParams): $CancellablePromise<void> {
|
||||
return $Call.ByID(3824847887, p);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenURL launches the user's preferred browser to display url. Mirrors the
|
||||
* Fyne client's openURL helper so the SSO flow can pop the verification page
|
||||
* the same way as the legacy UI — WebKitGTK's window.open is blocked by the
|
||||
* embedded webview, and asking the user to copy/paste defeats the point of
|
||||
* SSO. Honors $BROWSER first, then falls back to the platform default.
|
||||
*/
|
||||
export function OpenURL(url: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(3786555598, url);
|
||||
}
|
||||
|
||||
export function Up(p: $models.UpParams): $CancellablePromise<void> {
|
||||
return $Call.ByID(3381092588, p);
|
||||
}
|
||||
|
||||
export function WaitSSOLogin(p: $models.WaitSSOParams): $CancellablePromise<string> {
|
||||
return $Call.ByID(1751351500, p);
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.LoginResult.createFrom;
|
||||
@@ -0,0 +1,35 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Debug groups debug / log-level / packet-trace RPCs.
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function Bundle(p: $models.DebugBundleParams): $CancellablePromise<$models.DebugBundleResult> {
|
||||
return $Call.ByID(617551238, p).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function GetLogLevel(): $CancellablePromise<$models.LogLevel> {
|
||||
return $Call.ByID(3832950014).then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function SetLogLevel(lvl: $models.LogLevel): $CancellablePromise<void> {
|
||||
return $Call.ByID(4122411498, lvl);
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.DebugBundleResult.createFrom;
|
||||
const $$createType1 = $models.LogLevel.createFrom;
|
||||
@@ -0,0 +1,29 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Forwarding groups the daemon RPCs that surface exposed/forwarded services.
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* List returns the current set of forwarding rules from the daemon's
|
||||
* reverse proxy. The frontend renders these as the "exposed services" list.
|
||||
*/
|
||||
export function List(): $CancellablePromise<$models.ForwardingRule[]> {
|
||||
return $Call.ByID(3831092172).then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
});
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.ForwardingRule.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
import * as Connection from "./connection.js";
|
||||
import * as Debug from "./debug.js";
|
||||
import * as Forwarding from "./forwarding.js";
|
||||
import * as Networks from "./networks.js";
|
||||
import * as Peers from "./peers.js";
|
||||
import * as Profiles from "./profiles.js";
|
||||
import * as Settings from "./settings.js";
|
||||
import * as Update from "./update.js";
|
||||
export {
|
||||
Connection,
|
||||
Debug,
|
||||
Forwarding,
|
||||
Networks,
|
||||
Peers,
|
||||
Profiles,
|
||||
Settings,
|
||||
Update
|
||||
};
|
||||
|
||||
export {
|
||||
ActiveProfile,
|
||||
Config,
|
||||
ConfigParams,
|
||||
DebugBundleParams,
|
||||
DebugBundleResult,
|
||||
Features,
|
||||
ForwardingRule,
|
||||
LocalPeer,
|
||||
LogLevel,
|
||||
LoginParams,
|
||||
LoginResult,
|
||||
LogoutParams,
|
||||
Network,
|
||||
PeerLink,
|
||||
PeerStatus,
|
||||
PortInfo,
|
||||
PortRange,
|
||||
Profile,
|
||||
ProfileRef,
|
||||
SelectNetworksParams,
|
||||
SetConfigParams,
|
||||
Status,
|
||||
SystemEvent,
|
||||
UpParams,
|
||||
UpdateAvailable,
|
||||
UpdateProgress,
|
||||
UpdateResult,
|
||||
WaitSSOParams
|
||||
} from "./models.js";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Networks groups the daemon RPCs that read and toggle routed networks.
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function Deselect(p: $models.SelectNetworksParams): $CancellablePromise<void> {
|
||||
return $Call.ByID(3382210947, p);
|
||||
}
|
||||
|
||||
export function List(): $CancellablePromise<$models.Network[]> {
|
||||
return $Call.ByID(1550842096).then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function Select(p: $models.SelectNetworksParams): $CancellablePromise<void> {
|
||||
return $Call.ByID(1339338400, p);
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.Network.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Peers serves the dashboard data: one polled Status RPC and a long-running
|
||||
* SubscribeEvents stream that re-emits every event over the Wails event bus.
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* Get returns the current daemon status snapshot.
|
||||
*/
|
||||
export function Get(): $CancellablePromise<$models.Status> {
|
||||
return $Call.ByID(3266051360).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch starts the background loops that feed the frontend:
|
||||
* - statusStreamLoop: push-driven snapshots on connection-state change
|
||||
* (Connected/Disconnected/Connecting, peer list, address). Drives the
|
||||
* tray icon, Status page, and Peers page.
|
||||
* - toastStreamLoop: DNS / network / auth / connectivity / update
|
||||
* SystemEvent stream. Drives OS notifications, the Recent Events
|
||||
* list, and the update-overlay flag. The daemon-side RPC is named
|
||||
* SubscribeEvents — only the loop's local alias differs to keep the
|
||||
* two streams distinguishable in this file.
|
||||
*
|
||||
* Safe to call once at boot; both loops self-restart on stream errors
|
||||
* via exponential backoff.
|
||||
*/
|
||||
export function Watch(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2799871735);
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.Status.createFrom;
|
||||
@@ -0,0 +1,52 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Profiles groups the daemon RPCs that manage named profiles.
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function Add(p: $models.ProfileRef): $CancellablePromise<void> {
|
||||
return $Call.ByID(722930578, p);
|
||||
}
|
||||
|
||||
export function GetActive(): $CancellablePromise<$models.ActiveProfile> {
|
||||
return $Call.ByID(3458449443).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function List(username: string): $CancellablePromise<$models.Profile[]> {
|
||||
return $Call.ByID(3702185167, username).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function Remove(p: $models.ProfileRef): $CancellablePromise<void> {
|
||||
return $Call.ByID(2365690315, p);
|
||||
}
|
||||
|
||||
export function Switch(p: $models.ProfileRef): $CancellablePromise<void> {
|
||||
return $Call.ByID(3209858855, p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Username returns the OS username the daemon expects for profile lookups.
|
||||
* The frontend calls this once at boot and reuses the result.
|
||||
*/
|
||||
export function Username(): $CancellablePromise<string> {
|
||||
return $Call.ByID(262345647);
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.ActiveProfile.createFrom;
|
||||
const $$createType1 = $models.Profile.createFrom;
|
||||
const $$createType2 = $Create.Array($$createType1);
|
||||
@@ -0,0 +1,35 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Settings groups the daemon RPCs that read and write the daemon config.
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function GetConfig(p: $models.ConfigParams): $CancellablePromise<$models.Config> {
|
||||
return $Call.ByID(59246988, p).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function GetFeatures(): $CancellablePromise<$models.Features> {
|
||||
return $Call.ByID(2056724965).then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function SetConfig(p: $models.SetConfigParams): $CancellablePromise<void> {
|
||||
return $Call.ByID(26939944, p);
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.Config.createFrom;
|
||||
const $$createType1 = $models.Features.createFrom;
|
||||
@@ -0,0 +1,41 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Update groups the RPCs that drive the enforced-update install flow.
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function GetInstallerResult(): $CancellablePromise<$models.UpdateResult> {
|
||||
return $Call.ByID(2533624807).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit asks the host application to exit. The /update page calls this once
|
||||
* the daemon-side installer has reported success, mirroring the legacy
|
||||
* Fyne UI's app.Quit() in showInstallerResult. Schedules the actual exit
|
||||
* off the calling goroutine so the JS-side caller's response can return
|
||||
* before the runtime tears down.
|
||||
*/
|
||||
export function Quit(): $CancellablePromise<void> {
|
||||
return $Call.ByID(409602657);
|
||||
}
|
||||
|
||||
export function Trigger(): $CancellablePromise<$models.UpdateResult> {
|
||||
return $Call.ByID(166270378).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.UpdateResult.createFrom;
|
||||
@@ -0,0 +1,28 @@
|
||||
//@ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as services$0 from "../../../../netbirdio/netbird/client/ui/services/models.js";
|
||||
|
||||
function configure() {
|
||||
Object.freeze(Object.assign($Create.Events, {
|
||||
"netbird:event": $$createType0,
|
||||
"netbird:status": $$createType1,
|
||||
"netbird:update:available": $$createType2,
|
||||
"netbird:update:progress": $$createType3,
|
||||
}));
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = services$0.SystemEvent.createFrom;
|
||||
const $$createType1 = services$0.Status.createFrom;
|
||||
const $$createType2 = services$0.UpdateAvailable.createFrom;
|
||||
const $$createType3 = services$0.UpdateProgress.createFrom;
|
||||
|
||||
configure();
|
||||
21
client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
vendored
Normal file
21
client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import type { Events } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import type * as services$0 from "../../../../netbirdio/netbird/client/ui/services/models.js";
|
||||
|
||||
declare module "@wailsio/runtime" {
|
||||
namespace Events {
|
||||
interface CustomEvents {
|
||||
"netbird:event": services$0.SystemEvent;
|
||||
"netbird:status": services$0.Status;
|
||||
"netbird:update:available": services$0.UpdateAvailable;
|
||||
"netbird:update:progress": services$0.UpdateProgress;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
import * as NotificationService from "./notificationservice.js";
|
||||
export {
|
||||
NotificationService
|
||||
};
|
||||
|
||||
export {
|
||||
NotificationAction,
|
||||
NotificationCategory,
|
||||
NotificationOptions
|
||||
} from "./models.js";
|
||||
@@ -0,0 +1,107 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* NotificationAction represents an action button for a notification.
|
||||
*/
|
||||
export class NotificationAction {
|
||||
"id"?: string;
|
||||
"title"?: string;
|
||||
|
||||
/**
|
||||
* (macOS-specific)
|
||||
*/
|
||||
"destructive"?: boolean;
|
||||
|
||||
/** Creates a new NotificationAction instance. */
|
||||
constructor($$source: Partial<NotificationAction> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NotificationAction instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): NotificationAction {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new NotificationAction($$parsedSource as Partial<NotificationAction>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationCategory groups actions for notifications.
|
||||
*/
|
||||
export class NotificationCategory {
|
||||
"id"?: string;
|
||||
"actions"?: NotificationAction[];
|
||||
"hasReplyField"?: boolean;
|
||||
"replyPlaceholder"?: string;
|
||||
"replyButtonTitle"?: string;
|
||||
|
||||
/** Creates a new NotificationCategory instance. */
|
||||
constructor($$source: Partial<NotificationCategory> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NotificationCategory instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): NotificationCategory {
|
||||
const $$createField1_0 = $$createType1;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("actions" in $$parsedSource) {
|
||||
$$parsedSource["actions"] = $$createField1_0($$parsedSource["actions"]);
|
||||
}
|
||||
return new NotificationCategory($$parsedSource as Partial<NotificationCategory>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationOptions contains configuration for a notification
|
||||
*/
|
||||
export class NotificationOptions {
|
||||
"id": string;
|
||||
"title": string;
|
||||
|
||||
/**
|
||||
* (macOS and Linux only)
|
||||
*/
|
||||
"subtitle"?: string;
|
||||
"body"?: string;
|
||||
"categoryId"?: string;
|
||||
"data"?: { [_ in string]?: any };
|
||||
|
||||
/** Creates a new NotificationOptions instance. */
|
||||
constructor($$source: Partial<NotificationOptions> = {}) {
|
||||
if (!("id" in $$source)) {
|
||||
this["id"] = "";
|
||||
}
|
||||
if (!("title" in $$source)) {
|
||||
this["title"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NotificationOptions instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): NotificationOptions {
|
||||
const $$createField5_0 = $$createType2;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("data" in $$parsedSource) {
|
||||
$$parsedSource["data"] = $$createField5_0($$parsedSource["data"]);
|
||||
}
|
||||
return new NotificationOptions($$parsedSource as Partial<NotificationOptions>);
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = NotificationAction.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType2 = $Create.Map($Create.Any, $Create.Any);
|
||||
@@ -0,0 +1,62 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Service represents the notifications service
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function CheckNotificationAuthorization(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(2216952893);
|
||||
}
|
||||
|
||||
export function RegisterNotificationCategory(category: $models.NotificationCategory): $CancellablePromise<void> {
|
||||
return $Call.ByID(2917562919, category);
|
||||
}
|
||||
|
||||
export function RemoveAllDeliveredNotifications(): $CancellablePromise<void> {
|
||||
return $Call.ByID(3956282340);
|
||||
}
|
||||
|
||||
export function RemoveAllPendingNotifications(): $CancellablePromise<void> {
|
||||
return $Call.ByID(108821341);
|
||||
}
|
||||
|
||||
export function RemoveDeliveredNotification(identifier: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(975691940, identifier);
|
||||
}
|
||||
|
||||
export function RemoveNotification(identifier: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(3966653866, identifier);
|
||||
}
|
||||
|
||||
export function RemoveNotificationCategory(categoryID: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(2032615554, categoryID);
|
||||
}
|
||||
|
||||
export function RemovePendingNotification(identifier: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(3729049703, identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public methods that delegate to the implementation.
|
||||
*/
|
||||
export function RequestNotificationAuthorization(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(3933442950);
|
||||
}
|
||||
|
||||
export function SendNotification(options: $models.NotificationOptions): $CancellablePromise<void> {
|
||||
return $Call.ByID(3968228732, options);
|
||||
}
|
||||
|
||||
export function SendNotificationWithActions(options: $models.NotificationOptions): $CancellablePromise<void> {
|
||||
return $Call.ByID(1886542847, options);
|
||||
}
|
||||
12
client/ui/frontend/index.html
Normal file
12
client/ui/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/app-new.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
53
client/ui/frontend/package.json
Normal file
53
client/ui/frontend/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"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",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\""
|
||||
},
|
||||
"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-visually-hidden": "^1.2.4",
|
||||
"@wailsio/runtime": "latest",
|
||||
"chroma-js": "^3.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-router-dom": "^7.1.3",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chroma-js": "^3.1.2",
|
||||
"@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",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
2606
client/ui/frontend/pnpm-lock.yaml
generated
Normal file
2606
client/ui/frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
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: {},
|
||||
},
|
||||
};
|
||||
168
client/ui/frontend/settings.md
Normal file
168
client/ui/frontend/settings.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Settings — Tabs & Controls
|
||||
|
||||
Each row has a title and short description. Booleans default to **toggle switch**; pick another control only when noted.
|
||||
|
||||
Tab order: **General · Network · Security · SSH · Advanced · Troubleshooting · About**.
|
||||
|
||||
---
|
||||
|
||||
## 1. General
|
||||
|
||||
App behavior + how the client connects.
|
||||
|
||||
### General
|
||||
|
||||
- **Connect on startup** — `disableAutoConnect` (inverted) · *toggle switch*
|
||||
- Automatically connect to NetBird when the app launches.
|
||||
- **Show notifications** — `disableNotifications` (inverted) · *toggle switch*
|
||||
- Show desktop notifications for connection events and updates.
|
||||
|
||||
### Connection
|
||||
|
||||
- **Management Server** — `managementUrl` · *label + help text + (text input next to inline Save button)*
|
||||
- Help text sits between the label and the input. The NetBird management server this client connects to; saving reconnects to apply the new server. Save button persists explicitly (in addition to the global debounced auto-save) since changing the server triggers a reconnect.
|
||||
|
||||
---
|
||||
|
||||
## 2. Network
|
||||
|
||||
Routing and DNS — how the daemon reaches peers and resolves names.
|
||||
|
||||
### Connectivity
|
||||
|
||||
- **Lazy connections** — `lazyConnectionEnabled` · *toggle switch*
|
||||
- Only establish peer tunnels on first traffic instead of eagerly at startup.
|
||||
- **Network monitor** — `networkMonitor` · *toggle switch*
|
||||
- Reconnect automatically when the host network changes (Wi-Fi switch, VPN, sleep/wake).
|
||||
|
||||
### Routing & DNS
|
||||
|
||||
- **Enable DNS** — `disableDns` (inverted) · *toggle switch*
|
||||
- Apply NetBird-managed DNS settings to the host resolver.
|
||||
- **Enable client routes** — `disableClientRoutes` (inverted) · *toggle switch*
|
||||
- Accept routes advertised by other peers so this client can reach their networks.
|
||||
- **Enable server routes** — `disableServerRoutes` (inverted) · *toggle switch*
|
||||
- Advertise this host's local routes to other peers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Security
|
||||
|
||||
Firewall and on-the-wire encryption — what's blocked and how the tunnel is protected.
|
||||
|
||||
### Firewall
|
||||
|
||||
- **Block inbound traffic** — `blockInbound` · *toggle switch*
|
||||
- Drop all unsolicited inbound traffic on the NetBird interface.
|
||||
- **Block LAN access** — `blockLanAccess` · *toggle switch*
|
||||
- Prevent peers from reaching this host's local network.
|
||||
|
||||
### Encryption
|
||||
|
||||
- **Quantum-resistant encryption** — `rosenpassEnabled` · *toggle switch*
|
||||
- Add a post-quantum key exchange (Rosenpass) on top of WireGuard.
|
||||
- **Permissive mode** — `rosenpassPermissive` · *toggle switch* (nested, only when above is on)
|
||||
- Allow connections to peers without quantum-resistance support.
|
||||
|
||||
---
|
||||
|
||||
## 4. SSH
|
||||
|
||||
NetBird SSH server config. Master switch at the top; sub-toggles greyed out when the master is off.
|
||||
|
||||
### Server
|
||||
|
||||
- **Allow SSH** — `serverSshAllowed` · *toggle switch* (master)
|
||||
- Run the NetBird SSH server on this host so other peers can connect to it.
|
||||
|
||||
### Capabilities
|
||||
|
||||
- **Allow root login** — `enableSshRoot` · *toggle switch*
|
||||
- Permit incoming SSH sessions to authenticate as `root`.
|
||||
- **Enable SFTP** — `enableSshSftp` · *toggle switch*
|
||||
- Allow file transfers over the NetBird SSH server.
|
||||
- **Local port forwarding** — `enableSshLocalPortForwarding` · *toggle switch*
|
||||
- Allow clients to forward local ports through this host.
|
||||
- **Remote port forwarding** — `enableSshRemotePortForwarding` · *toggle switch*
|
||||
- Allow clients to expose remote ports back through this host.
|
||||
|
||||
### Authentication
|
||||
|
||||
- **Disable SSH auth** — `disableSshAuth` · *toggle switch*
|
||||
- Skip JWT authentication for incoming SSH sessions. **Insecure — diagnostics only.**
|
||||
- **JWT cache TTL** — `sshJwtCacheTtl` · *number input (seconds)*
|
||||
- How long verified JWTs are cached before re-validation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Advanced
|
||||
|
||||
Power-user knobs: tunnel security, interface tuning, and log verbosity.
|
||||
|
||||
### Security
|
||||
|
||||
- **Pre-shared key** — `preSharedKey` · *label + help text + password input with reveal toggle*
|
||||
- Help text sits between the label and the input. Optional WireGuard pre-shared key for an extra layer of symmetric encryption; must match the value on every peer.
|
||||
|
||||
### Interface
|
||||
|
||||
- **Name** — `interfaceName` · *text input*
|
||||
- Name of the WireGuard network interface created on this host.
|
||||
- **WireGuard Port** — `wireguardPort` · *number input*
|
||||
- Local UDP port the WireGuard interface listens on.
|
||||
- **MTU** — `mtu` · *number input*
|
||||
- Maximum transmission unit for the WireGuard interface.
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
Everything you reach for when something is wrong.
|
||||
|
||||
### Debug bundle
|
||||
|
||||
Friendly intro line on top: *"A debug bundle helps NetBird support investigate connection problems. It's a zip file with logs and system details from this device."*
|
||||
|
||||
Toggle rows:
|
||||
|
||||
- **Anonymize personal data** — `anonymize` · *toggle switch* · default **on**
|
||||
- Replace IPs, hostnames, and peer names before saving.
|
||||
- **Include system info** — `systemInfo` · *toggle switch* · default **on**
|
||||
- Include OS, kernel, network interfaces, and routing tables.
|
||||
- **Send to NetBird support** — *toggle switch* · default **off**
|
||||
- Uploads the bundle to a hardcoded NetBird endpoint (`NETBIRD_UPLOAD_URL` constant). On success the user gets a short upload key to share with support. Local copy is always kept too.
|
||||
- **Capture detailed (trace) logs** — *toggle switch* · default **off**
|
||||
- Nested *Capture for [N] minutes* number input (1–30, suffix "min", default 3).
|
||||
- When enabled, the daemon's log level is switched to trace, NetBird is brought down and back up, the UI captures for the configured duration, the original log level is restored, then the bundle is created with `logFileCount: 5` (vs 1 in plain mode).
|
||||
- User-facing warning baked into the help text: "NetBird will briefly disconnect."
|
||||
|
||||
**Create bundle** — primary button. Disabled while running. Shows "Creating bundle…" label.
|
||||
|
||||
### Status / result block
|
||||
|
||||
Renders below the button while running and after completion.
|
||||
|
||||
- **Running** — bordered card with spinner + stage text. Stages: *Switching to trace logging…* → *Reconnecting NetBird…* → *Capturing logs — m:ss / m:ss* (countdown) → *Restoring previous log level…* → *Building bundle…* → *Uploading to NetBird…* (last only when upload toggle on; trace stages skipped when trace off).
|
||||
- **Done — uploaded**: bordered card with the upload key in a copyable code block + "Share this key with NetBird support so they can find your bundle.". Below, a smaller card with the local path + Copy + Reveal (file://) buttons + admin-privilege note.
|
||||
- **Done — local only**: single card with "Bundle saved to:" + path + Copy + Reveal + admin note.
|
||||
- **Partial — upload failed**: red banner ("Upload failed: <reason>. The bundle is still saved locally.") above the local path card.
|
||||
- **Error** (no bundle produced): red banner with the error message + a **Try again** button next to Create.
|
||||
|
||||
---
|
||||
|
||||
## 7. About
|
||||
|
||||
Two-row layout. Top row pairs the app icon with the product name + versions; everything else stacks below full-width.
|
||||
|
||||
**Top row** (icon left, info right):
|
||||
|
||||
1. **App icon** — `netbird-app-icon.svg`, `w-24 h-24`, rounded corners, subtle border (`border-nb-gray-800`).
|
||||
2. **NetBird** heading + version lines:
|
||||
- **GUI v{x.y.z}** — from `frontend/package.json` at build time
|
||||
- **Client v{x.y.z}** — from `Status.daemonVersion`
|
||||
|
||||
**Below the top row**, in order:
|
||||
|
||||
3. **Update banner** *(visible only when an event in `Status.events` carries `metadata["new_version_available"]`)* — "Version X.Y.Z is available." + a **What's new?** link → GitHub release page for that version, plus a **Restart now** primary button → `Update.Trigger()`.
|
||||
4. **Copyright** — "© {current year} NetBird. All Rights Reserved." (year from `new Date().getFullYear()`).
|
||||
5. **Legal links** — Imprint · Privacy · CLA · Terms of Service. Each opens via Wails `Browser.OpenURL` with `window.open` fallback.
|
||||
34
client/ui/frontend/src/App.tsx
Normal file
34
client/ui/frontend/src/App.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import Layout from "./Layout";
|
||||
import Status from "./pages/Status";
|
||||
import Settings from "./pages/Settings";
|
||||
import Networks from "./pages/Networks";
|
||||
import Peers from "./pages/Peers";
|
||||
import Profiles from "./pages/Profiles";
|
||||
import Debug from "./pages/Debug";
|
||||
import Update from "./pages/Update";
|
||||
import QuickActions from "./pages/QuickActions";
|
||||
import LoginUrl from "./pages/LoginUrl";
|
||||
import Login from "./pages/Login";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/quick" element={<QuickActions />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/login-url" element={<LoginUrl />} />
|
||||
<Route path="/update" element={<Update />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Status />} />
|
||||
<Route path="peers" element={<Peers />} />
|
||||
<Route path="networks" element={<Networks />} />
|
||||
<Route path="profiles" element={<Profiles />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="debug" element={<Debug />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
37
client/ui/frontend/src/app-new.tsx
Normal file
37
client/ui/frontend/src/app-new.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./globals.css";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import QuickActions from "@/screens/QuickActions.tsx";
|
||||
import LoginUrl from "@/screens/LoginUrl.tsx";
|
||||
import Update from "@/screens/Update.tsx";
|
||||
import { AppLayout } from "@/layouts/AppLayout.tsx";
|
||||
import { Main } from "@/layouts/Main.tsx";
|
||||
import { Settings } from "@/modules/settings/Settings.tsx";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { welcome } from "@/lib/welcome";
|
||||
|
||||
welcome();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/quick" element={<QuickActions />} />
|
||||
<Route path="/login" element={<LoginUrl />} />
|
||||
<Route path="/update" element={<Update />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Main />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={"/"} replace />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
BIN
client/ui/frontend/src/assets/fonts/InterVariable.ttf
Normal file
BIN
client/ui/frontend/src/assets/fonts/InterVariable.ttf
Normal file
Binary file not shown.
BIN
client/ui/frontend/src/assets/fonts/JetBrainsMonoVariable.ttf
Normal file
BIN
client/ui/frontend/src/assets/fonts/JetBrainsMonoVariable.ttf
Normal file
Binary file not shown.
BIN
client/ui/frontend/src/assets/logos/fonts/inter.ttf
Normal file
BIN
client/ui/frontend/src/assets/logos/fonts/inter.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_8361_2)">
|
||||
<rect width="350" height="350" fill="#FCFCFC"/>
|
||||
<rect x="-32" y="237" width="422" height="113" fill="#FCFCFC"/>
|
||||
<path d="M219.319 91.9371C191.917 94.453 178.279 110.24 173.125 118.228L93.0557 257.039H189.655L284.934 91.9371H219.319Z" fill="#F68330"/>
|
||||
<path d="M189.731 257.041L58 117.224C58 117.224 206.952 77.1591 221.471 202.133L189.731 257.041Z" fill="#F68330"/>
|
||||
<path d="M170.168 123.395L129.756 193.461L189.651 257.049L221.389 202.015C216.362 159.057 195.433 135.596 170.168 123.332" fill="#F05252"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_8361_2">
|
||||
<rect width="350" height="350" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 755 B |
14
client/ui/frontend/src/assets/logos/netbird-app-icon.svg
Normal file
14
client/ui/frontend/src/assets/logos/netbird-app-icon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_8281_6)">
|
||||
<rect width="350" height="350" fill="#181A1C"/>
|
||||
<rect x="-32" y="237" width="422" height="113" fill="#181A1C"/>
|
||||
<path d="M219.319 91.9371C191.917 94.453 178.279 110.24 173.125 118.228L93.0557 257.039H189.655L284.934 91.9371H219.319Z" fill="#F68330"/>
|
||||
<path d="M189.731 257.041L58 117.224C58 117.224 206.952 77.1591 221.471 202.133L189.731 257.041Z" fill="#F68330"/>
|
||||
<path d="M170.168 123.395L129.756 193.461L189.651 257.049L221.389 202.015C216.362 159.057 195.433 135.596 170.168 123.332" fill="#F05252"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_8281_6">
|
||||
<rect width="350" height="350" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 755 B |
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 |
167
client/ui/frontend/src/components/Button.tsx
Normal file
167
client/ui/frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import classNames from "classnames";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
copy?: string;
|
||||
}
|
||||
|
||||
export const buttonVariants = cva(
|
||||
[
|
||||
"relative",
|
||||
"text-sm focus:z-10 focus:ring-2 font-semibold focus:outline-none whitespace-nowrap shadow-sm",
|
||||
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-900 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500",
|
||||
],
|
||||
secondary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910",
|
||||
],
|
||||
secondaryLighter: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60",
|
||||
],
|
||||
subtle: [
|
||||
"bg-nb-gray-50 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60 border-nb-gray-200 text-nb-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40",
|
||||
"dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:border-nb-gray-200 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950",
|
||||
],
|
||||
input: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80",
|
||||
],
|
||||
dropdown: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
tertiary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
white: [
|
||||
"focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900",
|
||||
],
|
||||
outline: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30",
|
||||
],
|
||||
"danger-outline": [
|
||||
"enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500",
|
||||
],
|
||||
"danger-text": [
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
|
||||
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
|
||||
],
|
||||
ghost: [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30",
|
||||
],
|
||||
danger: [
|
||||
"dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100",
|
||||
],
|
||||
},
|
||||
size: {
|
||||
xs: "text-xs py-2 px-3.5",
|
||||
xs2: "text-[0.78rem] py-2 px-4",
|
||||
sm: "text-sm py-[9px] px-4",
|
||||
md: "text-md py-[9px] px-4",
|
||||
lg: "text-lg py-[9px] px-4",
|
||||
},
|
||||
rounded: {
|
||||
true: "rounded-md",
|
||||
false: "",
|
||||
},
|
||||
border: {
|
||||
0: "border",
|
||||
1: "border border-transparent",
|
||||
2: "border border-t-0 border-b-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,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const iconSize = size === "xs" ? 12 : 14;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
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);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{copy !== undefined && (copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default Button;
|
||||
14
client/ui/frontend/src/components/Card.tsx
Normal file
14
client/ui/frontend/src/components/Card.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-nb-gray-200 bg-white p-4 dark:border-nb-gray-800 dark:bg-nb-gray-925",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
79
client/ui/frontend/src/components/CardNavItem.tsx
Normal file
79
client/ui/frontend/src/components/CardNavItem.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ComponentType, forwardRef } from "react";
|
||||
import { motion, HTMLMotionProps } from "framer-motion";
|
||||
import { LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = HTMLMotionProps<"button"> & {
|
||||
icon: ComponentType<LucideProps>;
|
||||
title: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
iconSize?: number;
|
||||
};
|
||||
|
||||
export const CardNavItem = forwardRef<HTMLButtonElement, Props>(
|
||||
function CardNavItem(
|
||||
{
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
active = false,
|
||||
iconSize = 15,
|
||||
className,
|
||||
type = "button",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
type={type}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-1.5 rounded-lg cursor-default outline-none text-left",
|
||||
"transition-colors duration-150",
|
||||
active ? "bg-nb-gray-930" : "hover:bg-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
|
||||
"transition-colors duration-150",
|
||||
active ? "bg-nb-gray-800" : "bg-nb-gray-920",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
className={cn(
|
||||
"transition-colors duration-150",
|
||||
active ? "text-nb-gray-200" : "text-nb-gray-400",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={"min-w-0"}>
|
||||
<h2
|
||||
className={cn(
|
||||
"font-medium text-[0.81rem] truncate",
|
||||
active ? "text-nb-gray-100" : "text-nb-gray-200",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium truncate",
|
||||
active ? "text-nb-gray-300" : "text-nb-gray-400",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
},
|
||||
);
|
||||
149
client/ui/frontend/src/components/Dialog.tsx
Normal file
149
client/ui/frontend/src/components/Dialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
forwardRef,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
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 { cn } from "@/lib/cn";
|
||||
|
||||
export const Root = DialogPrimitive.Root;
|
||||
export const Trigger = DialogPrimitive.Trigger;
|
||||
export const Close = DialogPrimitive.Close;
|
||||
export const Portal = DialogPrimitive.Portal;
|
||||
|
||||
export const Overlay = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(function DialogOverlay({ className, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 grid place-items-start overflow-y-auto py-16",
|
||||
"bg-black/40 backdrop-blur-sm",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||
"duration-150 ease-out",
|
||||
className,
|
||||
)}
|
||||
style={{ scrollbarGutter: "stable both-edges" }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showClose?: boolean;
|
||||
maxWidthClass?: string;
|
||||
};
|
||||
|
||||
export const Content = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Content>,
|
||||
ContentProps
|
||||
>(function DialogContent(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
showClose = true,
|
||||
maxWidthClass = "max-w-md",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<Overlay>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mx-auto relative z-[52] w-full outline-none ring-0",
|
||||
"focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0",
|
||||
"border border-nb-gray-900 bg-nb-gray py-6 shadow-2xl rounded-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-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1",
|
||||
"duration-150 ease-out",
|
||||
maxWidthClass,
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
|
||||
</VisuallyHidden>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
"absolute right-4 top-4 z-10 rounded-sm opacity-70 transition-opacity",
|
||||
"hover:opacity-100 focus:outline-none disabled:pointer-events-none",
|
||||
"text-nb-gray-300",
|
||||
)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</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(
|
||||
"text-sm text-nb-gray-400 mt-2 leading-snug",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type FooterProps = HTMLAttributes<HTMLDivElement> & {
|
||||
separator?: boolean;
|
||||
};
|
||||
|
||||
export const Footer = ({
|
||||
className,
|
||||
separator = true,
|
||||
...props
|
||||
}: FooterProps) => (
|
||||
<div className={cn(separator && "border-t border-nb-gray-900 mt-6")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-3",
|
||||
"px-8 pt-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
75
client/ui/frontend/src/components/FancyToggleSwitch.tsx
Normal file
75
client/ui/frontend/src/components/FancyToggleSwitch.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Label } from "@/components/Label";
|
||||
import { ToggleSwitch } from "@/components/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;
|
||||
dataCy?: string;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
textWrapperClassName?: string;
|
||||
}
|
||||
|
||||
export default function FancyToggleSwitch({
|
||||
value,
|
||||
onChange,
|
||||
helpText,
|
||||
label,
|
||||
children,
|
||||
disabled = false,
|
||||
dataCy,
|
||||
className,
|
||||
labelClassName,
|
||||
textWrapperClassName = "max-w-lg",
|
||||
}: Readonly<Props>) {
|
||||
const handleToggle = () => {
|
||||
if (disabled) return;
|
||||
onChange(!value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleToggle();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
role={"switch"}
|
||||
aria-checked={value}
|
||||
className={cn(
|
||||
"cursor-default transition-all duration-300 relative z-[1]",
|
||||
"inline-block text-left w-full",
|
||||
disabled && "opacity-30 pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex justify-between gap-10"}>
|
||||
<div className={cn(textWrapperClassName)}>
|
||||
<Label className={labelClassName}>{label}</Label>
|
||||
<HelpText margin={false}>{helpText}</HelpText>
|
||||
</div>
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<ToggleSwitch checked={value} onCheckedChange={onChange} dataCy={dataCy} />
|
||||
</div>
|
||||
</div>
|
||||
{children && value ? (
|
||||
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
client/ui/frontend/src/components/HelpText.tsx
Normal file
22
client/ui/frontend/src/components/HelpText.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = {
|
||||
children?: ReactNode;
|
||||
margin?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const HelpText = ({ children, margin = true, className }: Props) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[.81rem] dark:text-nb-gray-300 block font-light tracking-wide",
|
||||
margin && "mb-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export default HelpText;
|
||||
41
client/ui/frontend/src/components/IconButton.tsx
Normal file
41
client/ui/frontend/src/components/IconButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ComponentType, forwardRef } from "react";
|
||||
import { motion, HTMLMotionProps } from "framer-motion";
|
||||
import { LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = HTMLMotionProps<"button"> & {
|
||||
icon: ComponentType<LucideProps>;
|
||||
iconSize?: number;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
function IconButton(
|
||||
{
|
||||
icon: Icon,
|
||||
iconSize = 18,
|
||||
iconClassName,
|
||||
className,
|
||||
type = "button",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
type={type}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={cn(
|
||||
"h-11 w-11 flex items-center justify-center rounded-md cursor-default outline-none",
|
||||
"text-nb-gray-400 hover:text-nb-gray-300 hover:bg-nb-gray-930",
|
||||
"transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={iconSize} className={iconClassName} />
|
||||
</motion.button>
|
||||
);
|
||||
},
|
||||
);
|
||||
250
client/ui/frontend/src/components/Input.tsx
Normal file
250
client/ui/frontend/src/components/Input.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Label } from "@/components/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;
|
||||
prefixClassName?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
copy?: boolean;
|
||||
}
|
||||
|
||||
const inputVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-700",
|
||||
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
|
||||
],
|
||||
darker: [
|
||||
"dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-300 dark:border-nb-gray-800",
|
||||
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
|
||||
],
|
||||
error: [
|
||||
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-red-500 text-red-500",
|
||||
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
|
||||
],
|
||||
},
|
||||
prefixSuffixVariant: {
|
||||
default: [
|
||||
"dark:bg-nb-gray-900 border-neutral-200 dark:border-nb-gray-700 text-nb-gray-300",
|
||||
],
|
||||
error: ["dark:bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
label,
|
||||
customSuffix,
|
||||
customPrefix,
|
||||
icon,
|
||||
maxWidthClass = "",
|
||||
error,
|
||||
variant = "default",
|
||||
prefixClassName,
|
||||
showPasswordToggle = false,
|
||||
copy = false,
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
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 inputId = id ?? (label ? `input-${reactId}` : undefined);
|
||||
|
||||
const internalRef = useRef<HTMLInputElement | null>(null);
|
||||
const setRefs = (el: HTMLInputElement | null) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") ref(el);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
|
||||
};
|
||||
|
||||
const stepBy = (delta: 1 | -1) => {
|
||||
const el = internalRef.current;
|
||||
if (!el || el.disabled || el.readOnly) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
const stepAttr = el.step !== "" ? Number(el.step) : 1;
|
||||
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
|
||||
const min = el.min !== "" ? Number(el.min) : -Infinity;
|
||||
const max = el.max !== "" ? Number(el.max) : Infinity;
|
||||
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;
|
||||
setter?.call(el, String(next));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
const passwordToggle =
|
||||
isPasswordType && showPasswordToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className="hover:text-white transition-all pointer-events-auto"
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const onCopy = async () => {
|
||||
const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? "");
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const copyToggle = copy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="hover:text-white transition-all pointer-events-auto"
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const suffix = passwordToggle || copyToggle || customSuffix;
|
||||
const showStepper = isNumber;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full min-w-0">
|
||||
{label && <Label htmlFor={inputId}>{label}</Label>}
|
||||
<div className={cn("flex relative h-[40px] w-full", maxWidthClass)}>
|
||||
{customPrefix && (
|
||||
<div
|
||||
className={cn(
|
||||
inputVariants({
|
||||
prefixSuffixVariant: error ? "error" : "default",
|
||||
}),
|
||||
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||
"border items-center whitespace-nowrap",
|
||||
props.disabled && "opacity-40",
|
||||
prefixClassName,
|
||||
)}
|
||||
>
|
||||
{customPrefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
|
||||
props.disabled && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-grow min-w-0">
|
||||
<input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
ref={setRefs}
|
||||
{...props}
|
||||
className={cn(
|
||||
inputVariants({
|
||||
variant: error ? "error" : variant,
|
||||
}),
|
||||
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm",
|
||||
"file:bg-transparent file:text-sm file:font-medium file:border-0",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
customPrefix && "!border-l-0 !rounded-l-none",
|
||||
suffix && "!pr-9",
|
||||
icon && "!pl-10",
|
||||
"border",
|
||||
props.readOnly &&
|
||||
"!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
|
||||
showStepper &&
|
||||
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
{suffix && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
|
||||
props.disabled && "opacity-30",
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showStepper && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-[40px] shrink-0 overflow-hidden",
|
||||
"border border-l-0 rounded-r-md",
|
||||
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
error && "dark:border-red-500",
|
||||
props.disabled && "opacity-40 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Increase"
|
||||
onClick={() => stepBy(1)}
|
||||
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Decrease"
|
||||
onClick={() => stepBy(-1)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
|
||||
"border-t border-neutral-200 dark:border-nb-gray-700",
|
||||
)}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Input;
|
||||
40
client/ui/frontend/src/components/Label.tsx
Normal file
40
client/ui/frontend/src/components/Label.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-100 flex items-center gap-2",
|
||||
);
|
||||
|
||||
type LabelProps = ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants> & {
|
||||
as?: "label" | "div";
|
||||
};
|
||||
|
||||
export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
|
||||
{ className, as = "label", children, ...props },
|
||||
ref,
|
||||
) {
|
||||
const classes = cn(labelVariants(), className, "select-none");
|
||||
|
||||
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;
|
||||
130
client/ui/frontend/src/components/NetBirdConnectToggle.tsx
Normal file
130
client/ui/frontend/src/components/NetBirdConnectToggle.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/cn";
|
||||
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||
|
||||
export enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
Connecting = "connecting",
|
||||
Connected = "connected",
|
||||
Disconnecting = "disconnecting",
|
||||
}
|
||||
|
||||
type StateProps = {
|
||||
state: ConnectionState;
|
||||
};
|
||||
|
||||
type NetBirdConnectToggleProps = {
|
||||
state: ConnectionState;
|
||||
size?: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const NetBirdConnectToggle = ({ state, size = 140, onClick }: NetBirdConnectToggleProps) => {
|
||||
const [visualState, setVisualState] = useState(state);
|
||||
|
||||
useEffect(() => {
|
||||
setVisualState(state);
|
||||
}, [state]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (visualState === ConnectionState.Connected) {
|
||||
setVisualState(ConnectionState.Disconnecting);
|
||||
} else {
|
||||
setVisualState(ConnectionState.Connecting);
|
||||
}
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
const padding = size * 0.075;
|
||||
const borderGap = 2;
|
||||
const borderInset = padding - borderGap;
|
||||
const innerSize = size * 0.7;
|
||||
const logoSize = size * 0.26;
|
||||
const pingInset = size * 0.075;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.button
|
||||
className="rounded-full relative overflow-visible cursor-default outline-none border-none bg-transparent"
|
||||
style={{ padding }}
|
||||
onClick={handleClick}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<OuterRing state={visualState} />
|
||||
<BorderInnerRing state={visualState} inset={borderInset} />
|
||||
<InnerRing size={innerSize}>
|
||||
<NetBirdLogo state={visualState} logoSize={logoSize} />
|
||||
<PingRing state={visualState} inset={pingInset} />
|
||||
</InnerRing>
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OuterRing = ({ state }: StateProps) => {
|
||||
const isActive = state === ConnectionState.Connected || state === ConnectionState.Disconnecting;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 rounded-full transition-all",
|
||||
isActive ? "bg-netbird-500/20" : "bg-neutral-700",
|
||||
state === ConnectionState.Disconnecting && "animate-pulse-slow",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BorderInnerRing = ({ state, inset }: StateProps & { inset: number }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute rounded-full transition-all duration-1000",
|
||||
state === ConnectionState.Connected && "bg-netbird-600",
|
||||
state === ConnectionState.Disconnecting && "bg-conic-netbird animate-spin-slow",
|
||||
state !== ConnectionState.Connected && state !== ConnectionState.Disconnecting && "bg-neutral-500",
|
||||
)}
|
||||
style={{ inset }}
|
||||
/>
|
||||
);
|
||||
|
||||
const InnerRing = ({ children, size }: { children: React.ReactNode; size: number }) => (
|
||||
<div
|
||||
className="rounded-full bg-nb-gray flex items-center justify-center relative z-10 mx-auto"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const NetBirdLogo = ({ state, logoSize }: StateProps & { logoSize: number }) => {
|
||||
const isConnecting = state === ConnectionState.Connecting;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(isConnecting && "animate-pulse-slow")}
|
||||
style={isConnecting ? { animationDelay: "0.1s" } : undefined}
|
||||
>
|
||||
<img
|
||||
src={netbirdLogo}
|
||||
alt="NetBird"
|
||||
width={logoSize}
|
||||
className={cn(
|
||||
"filter transition-all duration-1000",
|
||||
state === ConnectionState.Disconnected ? "grayscale" : "grayscale-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PingRing = ({ state, inset }: StateProps & { inset: number }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"block absolute border-2 border-netbird rounded-full",
|
||||
state === ConnectionState.Connecting ? "animate-ping-slow" : "hidden",
|
||||
)}
|
||||
style={{ inset }}
|
||||
/>
|
||||
);
|
||||
91
client/ui/frontend/src/components/NetBirdVersionCard.tsx
Normal file
91
client/ui/frontend/src/components/NetBirdVersionCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Update as UpdateSvc } from "@bindings/services";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useStatus } from "@/hooks/useStatus";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
function openUrl(url: string) {
|
||||
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||
}
|
||||
|
||||
function formatLastChecked(date: Date) {
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function triggerUpdate() {
|
||||
UpdateSvc.Trigger().catch(() => {});
|
||||
}
|
||||
|
||||
export function NetBirdVersionCard() {
|
||||
const { status } = useStatus();
|
||||
const updateVersion = (status?.events ?? [])
|
||||
.map((e) => e.metadata?.["new_version_available"])
|
||||
.find((v): v is string => Boolean(v));
|
||||
|
||||
if (updateVersion) {
|
||||
return (
|
||||
<Card>
|
||||
<div>
|
||||
<Title>Version {updateVersion} is available.</Title>
|
||||
<Link
|
||||
url={`https://github.com/netbirdio/netbird/releases/tag/v${updateVersion}`}
|
||||
>
|
||||
What's new?
|
||||
</Link>
|
||||
</div>
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Restart Now
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={"max-w-md"}>
|
||||
<div>
|
||||
<Title>Last checked on {formatLastChecked(new Date())}</Title>
|
||||
<Link url={"https://github.com/netbirdio/netbird/releases/latest"}>Changelog</Link>
|
||||
</div>
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Check for updates
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-md flex 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 }: { children: ReactNode }) {
|
||||
return <p className={"text-sm font-semibold"}>{children}</p>;
|
||||
}
|
||||
|
||||
function Link({ url, children }: { url: string; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => openUrl(url)}
|
||||
className={
|
||||
"text-sm text-netbird hover:underline hover:underline-offset-4 hover:decoration-[0.5px] font-medium"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
73
client/ui/frontend/src/components/NewProfileDialog.tsx
Normal file
73
client/ui/frontend/src/components/NewProfileDialog.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import * as Dialog from "@/components/Dialog";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Button } from "@/components/Button";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreate: (name: string) => void;
|
||||
};
|
||||
|
||||
export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setName("");
|
||||
}, [open]);
|
||||
|
||||
const trimmed = name.trim();
|
||||
const canSubmit = trimmed.length > 0;
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onCreate(trimmed);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content
|
||||
maxWidthClass="max-w-md"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-8 pt-2">
|
||||
<Dialog.Title>New Profile</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Profiles let you keep separate NetBird connections
|
||||
side by side. Give your profile a memorable name.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div className="px-8 pt-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="e.g. Work"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
359
client/ui/frontend/src/components/ProfileSelector.tsx
Normal file
359
client/ui/frontend/src/components/ProfileSelector.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { ChevronDown, MoreVertical, PlusCircle, Search, Trash2, UserMinus } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { generateColorFromString } from "@/lib/color";
|
||||
import { NewProfileDialog } from "@/components/NewProfileDialog";
|
||||
|
||||
export type Profile = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const MOCK_PROFILES: Profile[] = [
|
||||
{ id: "default", name: "Default Profile" },
|
||||
{ id: "work", name: "Work" },
|
||||
{ id: "personal", name: "Personal" },
|
||||
{ id: "staging", name: "Staging" },
|
||||
{ id: "production", name: "Production" },
|
||||
{ id: "dev", name: "Development" },
|
||||
{ id: "qa", name: "QA Environment" },
|
||||
{ id: "demo", name: "Demo" },
|
||||
{ id: "client-acme", name: "Client - ACME" },
|
||||
{ id: "client-globex", name: "Client - Globex" },
|
||||
{ id: "client-initech", name: "Client - Initech" },
|
||||
{ id: "homelab", name: "Homelab" },
|
||||
{ id: "office-berlin", name: "Office Berlin" },
|
||||
{ id: "office-sf", name: "Office San Francisco" },
|
||||
{ id: "office-tokyo", name: "Office Tokyo" },
|
||||
{ id: "vpn-eu", name: "VPN EU" },
|
||||
{ id: "vpn-us", name: "VPN US" },
|
||||
{ id: "vpn-asia", name: "VPN Asia" },
|
||||
{ id: "test", name: "Test" },
|
||||
{ id: "sandbox", name: "Sandbox" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export const ProfileSelector = ({ email = "" }: Props) => {
|
||||
const [profiles, setProfiles] = useState<Profile[]>(MOCK_PROFILES);
|
||||
const [selectedId, setSelectedId] = useState<string>(MOCK_PROFILES[0].id);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
const selected = profiles.find((p) => p.id === selectedId) ?? profiles[0];
|
||||
|
||||
const sorted = [...profiles].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setSelectedId(id);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleDeregister = async (id: string) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
if (!profile) return;
|
||||
const result = await Dialogs.Warning({
|
||||
Title: "Deregister Profile",
|
||||
Message: `Are you sure you want to deregister "${profile.name}"? You will need to log in again to use it.`,
|
||||
Buttons: [
|
||||
{ Label: "Cancel", IsCancel: true },
|
||||
{ Label: "Deregister", IsDefault: true },
|
||||
],
|
||||
});
|
||||
if (result !== "Deregister") return;
|
||||
console.log("Deregister profile", id);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
if (!profile) return;
|
||||
const result = await Dialogs.Warning({
|
||||
Title: "Delete Profile",
|
||||
Message: `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`,
|
||||
Buttons: [
|
||||
{ Label: "Cancel", IsCancel: true },
|
||||
{ Label: "Delete", IsDefault: true },
|
||||
],
|
||||
});
|
||||
if (result !== "Delete") return;
|
||||
setProfiles((prev) => prev.filter((p) => p.id !== id));
|
||||
if (selectedId === id) {
|
||||
const remaining = profiles.filter((p) => p.id !== id);
|
||||
if (remaining.length > 0) setSelectedId(remaining[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewProfile = () => {
|
||||
setOpen(false);
|
||||
setNewOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateProfile = (name: string) => {
|
||||
const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`;
|
||||
setProfiles((prev) => [...prev, { id, name }]);
|
||||
setSelectedId(id);
|
||||
};
|
||||
|
||||
const initial = selected?.name.charAt(0).toUpperCase() ?? "?";
|
||||
const initialColor = generateColorFromString(selected?.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"h-11 rounded-md text-nb-gray-300 flex items-center gap-1 text-xs hover:bg-nb-gray-930 data-[state=open]:bg-nb-gray-930 px-2 -mx-1 outline-none cursor-default transition-colors duration-150"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold",
|
||||
email ? "h-7 w-7" : "h-6 w-6",
|
||||
)}
|
||||
style={{ color: initialColor }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-nowrap flex flex-col ml-1 text-left",
|
||||
email ? "mt-1" : "justify-center",
|
||||
)}
|
||||
>
|
||||
<span className={"leading-none text-nb-gray-200 font-semibold"}>
|
||||
{selected?.name ?? "No profile"}
|
||||
</span>
|
||||
{email && (
|
||||
<span className={"text-[0.73rem] font-normal text-nb-gray-300"}>
|
||||
{email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown size={14} className={"ml-2 mr-2"} />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"w-72 rounded-md border border-nb-gray-900 bg-nb-gray-930 shadow-lg",
|
||||
"p-1 z-50 origin-[var(--radix-popover-content-transform-origin)]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-1",
|
||||
"data-[side=top]:slide-in-from-bottom-1",
|
||||
"duration-150 ease-out",
|
||||
)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
"[&_[cmdk-input-wrapper]]:flex [&_[cmdk-input-wrapper]]:items-center",
|
||||
)}
|
||||
>
|
||||
<div className="px-1 pb-1">
|
||||
<div className="group flex items-center gap-2 px-2 h-8">
|
||||
<Search size={12} className="text-nb-gray-300 shrink-0" />
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder="Search profile by name..."
|
||||
className={cn(
|
||||
"w-full bg-transparent text-xs text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"outline-none border-none",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||
<ScrollArea.Viewport className="max-h-64 px-1 pb-1">
|
||||
<Command.List>
|
||||
<Command.Empty>
|
||||
<div className="flex flex-col items-center text-center px-4 pt-2 pb-3">
|
||||
<h3 className="text-xs font-semibold text-nb-gray-200">
|
||||
No Profiles Found
|
||||
</h3>
|
||||
<p className="text-[0.7rem] leading-snug text-nb-gray-400 mt-1 text-balance">
|
||||
Try a different search term or create a new
|
||||
profile.
|
||||
</p>
|
||||
</div>
|
||||
</Command.Empty>
|
||||
|
||||
{sorted.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
selected={profile.id === selectedId}
|
||||
onSelect={() => handleSelect(profile.id)}
|
||||
onDeregister={() => handleDeregister(profile.id)}
|
||||
onDelete={() => handleDelete(profile.id)}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation="vertical"
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
|
||||
<div className="h-px bg-nb-gray-920 -mx-1 my-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewProfile}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none",
|
||||
"text-netbird hover:bg-nb-gray-910",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"h-6 w-6 flex items-center justify-center rounded-md bg-nb-gray-900 shrink-0"
|
||||
}
|
||||
>
|
||||
<PlusCircle size={12} className="text-netbird" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold">New Profile</span>
|
||||
</button>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<NewProfileDialog
|
||||
open={newOpen}
|
||||
onOpenChange={setNewOpen}
|
||||
onCreate={handleCreateProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ProfileRowProps = {
|
||||
profile: Profile;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const ProfileRow = ({ profile, selected, onSelect, onDeregister, onDelete }: ProfileRowProps) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const initial = profile.name.charAt(0).toUpperCase();
|
||||
const initialColor = generateColorFromString(profile.name);
|
||||
|
||||
return (
|
||||
<Command.Item
|
||||
value={profile.name}
|
||||
keywords={[profile.id]}
|
||||
onSelect={() => onSelect()}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none",
|
||||
"data-[selected=true]:bg-nb-gray-910",
|
||||
selected && "bg-nb-gray-910",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-6 w-6 flex items-center justify-center rounded-md text-[0.65rem] font-semibold shrink-0 bg-nb-gray-900",
|
||||
"group-data-[selected=true]:bg-nb-gray-850",
|
||||
selected && "bg-nb-gray-850",
|
||||
)}
|
||||
style={{ color: initialColor }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 truncate text-xs",
|
||||
selected ? "text-nb-gray-200 font-semibold" : "text-nb-gray-200",
|
||||
)}
|
||||
>
|
||||
{profile.name}
|
||||
</span>
|
||||
|
||||
<DropdownMenu.Root open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"h-6 w-6 flex items-center justify-center rounded text-nb-gray-400 cursor-default",
|
||||
"hover:bg-nb-gray-800 hover:text-nb-gray-200 outline-none",
|
||||
"data-[state=open]:bg-nb-gray-800 data-[state=open]:text-nb-gray-200",
|
||||
)}
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"w-44 rounded-md border border-nb-gray-850 bg-nb-gray-910 shadow-lg p-1 z-50",
|
||||
)}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDeregister();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium",
|
||||
"text-xs text-nb-gray-200 data-[highlighted]:bg-nb-gray-850",
|
||||
)}
|
||||
>
|
||||
<UserMinus size={14} className="text-nb-gray-300" />
|
||||
<span>Deregister</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDelete();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium",
|
||||
"text-xs text-red-500 data-[highlighted]:bg-nb-gray-850",
|
||||
)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Delete Profile</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
30
client/ui/frontend/src/components/SearchInput.tsx
Normal file
30
client/ui/frontend/src/components/SearchInput.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { forwardRef, InputHTMLAttributes } from "react";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
iconSize?: number;
|
||||
};
|
||||
|
||||
export const SearchInput = forwardRef<HTMLInputElement, Props>(
|
||||
function SearchInput({ iconSize = 14, className, ...props }, ref) {
|
||||
return (
|
||||
<div className={"flex items-center gap-2 px-2 h-9"}>
|
||||
<SearchIcon
|
||||
size={iconSize}
|
||||
className={"text-nb-gray-300 shrink-0"}
|
||||
/>
|
||||
<input
|
||||
ref={ref}
|
||||
type={"text"}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-xs text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"outline-none border-none",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
48
client/ui/frontend/src/components/StatusPanel.tsx
Normal file
48
client/ui/frontend/src/components/StatusPanel.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Check, Loader2, XCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Variant = "loading" | "success" | "error";
|
||||
|
||||
type Props = {
|
||||
variant: Variant;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
|
||||
const VARIANTS: Record<Variant, { icon: ReactNode; className: string }> = {
|
||||
loading: {
|
||||
icon: <Loader2 className={"animate-spin text-nb-gray-950"} size={16} />,
|
||||
className: "bg-nb-gray-100",
|
||||
},
|
||||
success: {
|
||||
icon: <Check className={"text-white"} size={18} />,
|
||||
className: "bg-green-500",
|
||||
},
|
||||
error: {
|
||||
icon: <XCircle className={"text-white"} size={18} />,
|
||||
className: "bg-red-500",
|
||||
},
|
||||
};
|
||||
|
||||
export function StatusPanel({ variant, title, description, children, actions }: Props) {
|
||||
const { icon, className } = VARIANTS[variant];
|
||||
return (
|
||||
<div className={"absolute inset-0 flex flex-col items-center justify-center gap-5 px-8"}>
|
||||
<div className={cn("h-9 w-9 rounded-md flex items-center justify-center", className)}>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center gap-0.5 max-w-md text-center"}>
|
||||
<p className={"text-base font-medium text-nb-gray-50"}>{title}</p>
|
||||
{description && <p className={"text-sm text-nb-gray-300"}>{description}</p>}
|
||||
</div>
|
||||
|
||||
{children && <div className={"w-full max-w-md flex flex-col gap-3"}>{children}</div>}
|
||||
|
||||
{actions && <div className={"flex items-center gap-2"}>{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
client/ui/frontend/src/components/Switch.tsx
Normal file
42
client/ui/frontend/src/components/Switch.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function Switch({ checked, onChange, disabled, label, description }: Props) {
|
||||
return (
|
||||
<label className={cn("flex items-start gap-3", disabled && "opacity-60")}>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||||
checked ? "bg-netbird" : "bg-nb-gray-300 dark:bg-nb-gray-700",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{(label || description) && (
|
||||
<span className="flex flex-col">
|
||||
{label && <span className="text-sm font-medium">{label}</span>}
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-500">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
39
client/ui/frontend/src/components/SwitchItem.tsx
Normal file
39
client/ui/frontend/src/components/SwitchItem.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useSwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const SwitchItem = ({ value, children }: 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",
|
||||
"outline-none cursor-default",
|
||||
active
|
||||
? "text-nb-gray-100"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-200 active:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
44
client/ui/frontend/src/components/SwitchItemGroup.tsx
Normal file
44
client/ui/frontend/src/components/SwitchItemGroup.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { createContext, ReactNode, useContext, useId } 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;
|
||||
};
|
||||
|
||||
export const SwitchItemGroup = ({ value, onChange, children, className }: Props) => {
|
||||
const layoutId = useId();
|
||||
|
||||
return (
|
||||
<SwitchItemGroupContext.Provider value={{ value, layoutId }}>
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className={cn(
|
||||
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</RadioGroup.Root>
|
||||
</SwitchItemGroupContext.Provider>
|
||||
);
|
||||
};
|
||||
40
client/ui/frontend/src/components/Tabs.tsx
Normal file
40
client/ui/frontend/src/components/Tabs.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ReactNode, useState } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
interface Tab {
|
||||
value: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
initial?: string;
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, initial }: Props) {
|
||||
const [active, setActive] = useState(initial ?? tabs[0]?.value);
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 gap-1 border-b border-nb-gray-200 dark:border-nb-gray-800">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setActive(t.value)}
|
||||
className={cn(
|
||||
"border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
||||
active === t.value
|
||||
? "border-netbird text-netbird"
|
||||
: "border-transparent text-nb-gray-500 hover:text-nb-gray-800 dark:hover:text-nb-gray-200",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{tabs.find((t) => t.value === active)?.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
client/ui/frontend/src/components/ToggleSwitch.tsx
Normal file
77
client/ui/frontend/src/components/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import { cva, 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]",
|
||||
},
|
||||
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",
|
||||
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ToggleSwitch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
|
||||
SwitchVariants & { dataCy?: string }
|
||||
>(
|
||||
(
|
||||
{ className, size = "default", variant = "default", dataCy, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"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-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
|
||||
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 data-[state=unchecked]:translate-x-0 dark:bg-white",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
),
|
||||
);
|
||||
ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { ToggleSwitch };
|
||||
87
client/ui/frontend/src/components/VerticalTabs.tsx
Normal file
87
client/ui/frontend/src/components/VerticalTabs.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ComponentType, forwardRef } from "react";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const Root = forwardRef<
|
||||
HTMLDivElement,
|
||||
Omit<Tabs.TabsProps, "orientation">
|
||||
>(function VerticalTabsRoot({ className, ...props }, ref) {
|
||||
return (
|
||||
<Tabs.Root
|
||||
ref={ref}
|
||||
orientation={"vertical"}
|
||||
className={cn("flex flex-1 min-h-0 gap-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
|
||||
function VerticalTabsList({ className, ...props }, ref) {
|
||||
return (
|
||||
<Tabs.List
|
||||
ref={ref}
|
||||
className={cn("w-full flex flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type TriggerProps = Tabs.TabsTriggerProps & {
|
||||
icon: ComponentType<LucideProps>;
|
||||
title: string;
|
||||
iconSize?: number;
|
||||
};
|
||||
|
||||
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
function VerticalTabsTrigger(
|
||||
{ icon: Icon, title, iconSize = 16, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Tabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
|
||||
"transition-colors duration-150",
|
||||
"data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
className={cn(
|
||||
"shrink-0 ml-2 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
/>
|
||||
<h2
|
||||
className={cn(
|
||||
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(
|
||||
function VerticalTabsContent({ className, ...props }, ref) {
|
||||
return (
|
||||
<Tabs.Content
|
||||
ref={ref}
|
||||
className={cn("outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });
|
||||
29
client/ui/frontend/src/globals.css
Normal file
29
client/ui/frontend/src/globals.css
Normal file
@@ -0,0 +1,29 @@
|
||||
@font-face {
|
||||
font-family: "Inter Variable";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
src: url("./assets/fonts/InterVariable.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
|
||||
}
|
||||
|
||||
.wails-draggable {
|
||||
--wails-draggable: drag;
|
||||
}
|
||||
|
||||
.wails-no-draggable {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
36
client/ui/frontend/src/hooks/useStatus.ts
Normal file
36
client/ui/frontend/src/hooks/useStatus.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Peers } from "@bindings/services";
|
||||
import type { Status } from "@bindings/services/models.js";
|
||||
|
||||
const EVENT_STATUS = "netbird:status";
|
||||
|
||||
// useStatus loads the current daemon status once and re-renders whenever the
|
||||
// peers service emits a fresh snapshot over the Wails event bus.
|
||||
export function useStatus(): { status: Status | null; error: string | null } {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Peers.Get()
|
||||
.then((s) => {
|
||||
if (!cancelled) setStatus(s);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(String(e));
|
||||
});
|
||||
|
||||
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
|
||||
setStatus(ev.data);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
off();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { status, error };
|
||||
}
|
||||
19
client/ui/frontend/src/layouts/AppLayout.tsx
Normal file
19
client/ui/frontend/src/layouts/AppLayout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Header } from "@/layouts/Header.tsx";
|
||||
import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner.tsx";
|
||||
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
|
||||
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
||||
|
||||
export const AppLayout = () => {
|
||||
return (
|
||||
<ProfileProvider>
|
||||
<DebugBundleProvider>
|
||||
<div className={"relative flex h-full flex-col"}>
|
||||
<Header />
|
||||
<Outlet />
|
||||
<UpdateAvailableBanner />
|
||||
</div>
|
||||
</DebugBundleProvider>
|
||||
</ProfileProvider>
|
||||
);
|
||||
};
|
||||
25
client/ui/frontend/src/layouts/ConnectionStatus.tsx
Normal file
25
client/ui/frontend/src/layouts/ConnectionStatus.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
ConnectionState,
|
||||
NetBirdConnectToggle,
|
||||
} from "@/components/NetBirdConnectToggle.tsx";
|
||||
|
||||
export const ConnectionStatus = () => {
|
||||
return (
|
||||
<div className={"flex flex-col h-full items-center justify-center"}>
|
||||
<NetBirdConnectToggle state={ConnectionState.Connected} />
|
||||
<h1
|
||||
className={
|
||||
"text-base font-medium mt-8 text-nb-gray-200 tracking-wide"
|
||||
}
|
||||
>
|
||||
Connected
|
||||
</h1>
|
||||
<p className={"font-mono text-xs text-nb-gray-300 mt-1"}>
|
||||
peer-hostname.netbird.cloud
|
||||
</p>
|
||||
<p className={"font-mono text-xs text-nb-gray-300 mt-0.5"}>
|
||||
192.168.0.1
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
client/ui/frontend/src/layouts/Header.tsx
Normal file
31
client/ui/frontend/src/layouts/Header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { ProfileSelector } from "@/components/ProfileSelector.tsx";
|
||||
import { IconButton } from "@/components/IconButton.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export const Header = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isSettingsPage = location.pathname.startsWith("/settings");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"pt-4 shrink-0 cursor-default wails-draggable flex items-center justify-end px-4 gap-3 bg-gradient-to-b from-nb-gray-800/15"
|
||||
}
|
||||
>
|
||||
<div className={"ml-20"}>
|
||||
<ProfileSelector email={"eduard@netbird.io"} />
|
||||
</div>
|
||||
<IconButton
|
||||
icon={SettingsIcon}
|
||||
onClick={() => navigate(isSettingsPage ? "/" : "/settings")}
|
||||
className={cn(
|
||||
isSettingsPage &&
|
||||
"bg-nb-gray-910 hover:bg-nb-gray-910 text-nb-gray-200 hover:text-nb-gray-200",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
client/ui/frontend/src/layouts/Main.tsx
Normal file
22
client/ui/frontend/src/layouts/Main.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx";
|
||||
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||
import { Navigation } from "@/layouts/Navigation.tsx";
|
||||
import { Peers } from "@/modules/peers/Peers.tsx";
|
||||
|
||||
export const Main = () => {
|
||||
return (
|
||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col max-w-xs w-full shrink-0 items-center"
|
||||
}
|
||||
>
|
||||
<ConnectionStatus />
|
||||
<Navigation peersActive />
|
||||
</div>
|
||||
<MainRightSide>
|
||||
<Peers />
|
||||
</MainRightSide>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
client/ui/frontend/src/layouts/MainRightSide.tsx
Normal file
20
client/ui/frontend/src/layouts/MainRightSide.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const MainRightSide = ({ children }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable",
|
||||
"bg-nb-gray-935 border border-nb-gray-910",
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
client/ui/frontend/src/layouts/Navigation.tsx
Normal file
37
client/ui/frontend/src/layouts/Navigation.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { CardNavItem } from "@/components/CardNavItem.tsx";
|
||||
import {
|
||||
Layers3Icon,
|
||||
MonitorSmartphoneIcon,
|
||||
SquareArrowUpRight,
|
||||
} from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
peersActive?: boolean;
|
||||
onPeersClick?: () => void;
|
||||
};
|
||||
|
||||
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
|
||||
return (
|
||||
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
|
||||
<CardNavItem
|
||||
icon={MonitorSmartphoneIcon}
|
||||
title={"Peers"}
|
||||
description={"13 of 16 Online"}
|
||||
active={peersActive}
|
||||
onClick={onPeersClick}
|
||||
/>
|
||||
<CardNavItem
|
||||
icon={Layers3Icon}
|
||||
title={"Resources"}
|
||||
description={"13 of 16 Active"}
|
||||
iconSize={14}
|
||||
/>
|
||||
<CardNavItem
|
||||
icon={SquareArrowUpRight}
|
||||
title={"Exit Node Berlin"}
|
||||
description={"192.168..."}
|
||||
iconSize={14}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
27
client/ui/frontend/src/lib/MainModuleContext.tsx
Normal file
27
client/ui/frontend/src/lib/MainModuleContext.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createContext, ReactNode, useContext, useState } from "react";
|
||||
|
||||
export type MainModule = "peers" | "settings";
|
||||
|
||||
type Ctx = {
|
||||
active: MainModule;
|
||||
setActive: (m: MainModule) => void;
|
||||
};
|
||||
|
||||
const MainModuleContext = createContext<Ctx | null>(null);
|
||||
|
||||
export const MainModuleProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [active, setActive] = useState<MainModule>("peers");
|
||||
return (
|
||||
<MainModuleContext.Provider value={{ active, setActive }}>
|
||||
{children}
|
||||
</MainModuleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMainModule = () => {
|
||||
const ctx = useContext(MainModuleContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useMainModule must be used within MainModuleProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
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));
|
||||
}
|
||||
17
client/ui/frontend/src/lib/color.ts
Normal file
17
client/ui/frontend/src/lib/color.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import chroma from "chroma-js";
|
||||
|
||||
export const generateColorFromString = (str?: string) => {
|
||||
if (!str) return "#f68330";
|
||||
if (str.includes("System")) return "#808080";
|
||||
if (str.toLowerCase().startsWith("netbird")) return "#f68330";
|
||||
let hash = 0;
|
||||
str.split("").forEach((char) => {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
});
|
||||
let colour = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
colour += value.toString(16).padStart(2, "0");
|
||||
}
|
||||
return chroma(colour).saturate(2).luminance(0.4).hex();
|
||||
};
|
||||
23
client/ui/frontend/src/lib/welcome.ts
Normal file
23
client/ui/frontend/src/lib/welcome.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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://careers.netbird.io/
|
||||
OPEN SOURCE: https://github.com/netbirdio/netbird
|
||||
`;
|
||||
|
||||
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;",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useStatus } from "@/hooks/useStatus";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Update as UpdateSvc } from "@bindings/services";
|
||||
|
||||
// TODO: Shown only when management has auto updates enabled + there are updates available + force updates is disabled
|
||||
export const UpdateAvailableBanner = () => {
|
||||
const { status } = useStatus();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (import.meta.env.DEV) return null;
|
||||
|
||||
const updateVersion = (status?.events ?? [])
|
||||
.map((e) => e.metadata?.["new_version_available"])
|
||||
.find((v): v is string => Boolean(v));
|
||||
|
||||
if (!updateVersion || dismissed) return null;
|
||||
|
||||
const triggerUpdate = () => {
|
||||
UpdateSvc.Trigger().catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-4 left-1/2 -translate-x-1/2 z-50",
|
||||
"w-[calc(100%-2rem)] max-w-xl",
|
||||
"flex items-center justify-between gap-3",
|
||||
"rounded-xl border border-nb-gray-800 bg-white backdrop-blur",
|
||||
"px-2 py-2 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<p className={"text-sm text-nb-gray-900 pr-4 pl-2 font-medium"}>
|
||||
NetBird will update when you restart the app.
|
||||
</p>
|
||||
<div className={"flex gap-2"}>
|
||||
<Button variant={"subtle"} size={"xs"} onClick={() => setDismissed(true)}>
|
||||
Later
|
||||
</Button>
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Restart now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateAvailableBanner;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createContext, type ReactNode } from "react";
|
||||
import { useDebugBundle } from "@/modules/debug-bundle/useDebugBundle.ts";
|
||||
|
||||
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
|
||||
|
||||
export const DebugBundleContext =
|
||||
createContext<DebugBundleContextValue | null>(null);
|
||||
|
||||
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
|
||||
const value = useDebugBundle();
|
||||
return (
|
||||
<DebugBundleContext.Provider value={value}>
|
||||
{children}
|
||||
</DebugBundleContext.Provider>
|
||||
);
|
||||
};
|
||||
190
client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts
Normal file
190
client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
Connection as ConnectionSvc,
|
||||
Debug as DebugSvc,
|
||||
} from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
const TRACE_LOG_FILE_COUNT = 5;
|
||||
const PLAIN_LOG_FILE_COUNT = 1;
|
||||
|
||||
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 }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
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";
|
||||
|
||||
export const useDebugBundle = () => {
|
||||
const { activeProfile, username } = useProfile();
|
||||
const [anonymize, setAnonymize] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(true);
|
||||
const [trace, setTrace] = useState(true);
|
||||
const [traceMinutes, setTraceMinutes] = useState(1);
|
||||
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
|
||||
const [lastBundlePath, setLastBundlePath] = useState<string>("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const isRunning =
|
||||
stage.kind !== "idle" &&
|
||||
stage.kind !== "done" &&
|
||||
stage.kind !== "error";
|
||||
|
||||
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 checkAbort = () => {
|
||||
if (signal.aborted)
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
};
|
||||
|
||||
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
|
||||
let originalLevel = "info";
|
||||
let raisedLevel = false;
|
||||
|
||||
try {
|
||||
if (trace) {
|
||||
setStage({ kind: "preparing-trace" });
|
||||
try {
|
||||
const cur = await DebugSvc.GetLogLevel();
|
||||
if (cur?.level) originalLevel = cur.level;
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
checkAbort();
|
||||
await DebugSvc.SetLogLevel({ level: "trace" });
|
||||
raisedLevel = true;
|
||||
|
||||
checkAbort();
|
||||
setStage({ kind: "reconnecting" });
|
||||
try {
|
||||
await ConnectionSvc.Down();
|
||||
} catch {
|
||||
// already down
|
||||
}
|
||||
checkAbort();
|
||||
await ConnectionSvc.Up({
|
||||
profileName: activeProfile,
|
||||
username,
|
||||
});
|
||||
|
||||
const totalSec =
|
||||
Math.max(1, Math.min(30, traceMinutes)) * 60;
|
||||
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||
setStage({
|
||||
kind: "capturing",
|
||||
remainingSec: remaining,
|
||||
totalSec,
|
||||
});
|
||||
await sleep(1000, signal);
|
||||
}
|
||||
|
||||
setStage({ kind: "restoring-level" });
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||
raisedLevel = false;
|
||||
} catch {
|
||||
// restore is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
checkAbort();
|
||||
setStage({ kind: "bundling" });
|
||||
const logFileCount = trace
|
||||
? TRACE_LOG_FILE_COUNT
|
||||
: PLAIN_LOG_FILE_COUNT;
|
||||
|
||||
if (uploadUrl) setStage({ kind: "uploading" });
|
||||
const result = await DebugSvc.Bundle({
|
||||
anonymize,
|
||||
systemInfo,
|
||||
uploadUrl,
|
||||
logFileCount,
|
||||
});
|
||||
checkAbort();
|
||||
if (result.path) setLastBundlePath(result.path);
|
||||
setStage({
|
||||
kind: "done",
|
||||
result,
|
||||
uploadAttempted: Boolean(uploadUrl),
|
||||
});
|
||||
} catch (e) {
|
||||
if (isAbort(e)) {
|
||||
if (raisedLevel) {
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
setStage({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
setStage({ kind: "error", message: String(e) });
|
||||
} finally {
|
||||
if (abortRef.current === ctrl) abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openBundleDir = () => {
|
||||
if (!lastBundlePath) return;
|
||||
void DebugSvc.RevealFile(lastBundlePath).catch(() => {});
|
||||
};
|
||||
|
||||
return {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
systemInfo,
|
||||
setSystemInfo,
|
||||
upload,
|
||||
setUpload,
|
||||
trace,
|
||||
setTrace,
|
||||
traceMinutes,
|
||||
setTraceMinutes,
|
||||
stage,
|
||||
isRunning,
|
||||
lastBundlePath,
|
||||
run,
|
||||
cancel,
|
||||
reset,
|
||||
openBundleDir,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { DebugBundleContext } from "@/modules/debug-bundle/DebugBundleContext.tsx";
|
||||
|
||||
export const useDebugBundleContext = () => {
|
||||
const ctx = useContext(DebugBundleContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useDebugBundleContext must be used inside DebugBundleProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
53
client/ui/frontend/src/modules/peers/PeerFilters.tsx
Normal file
53
client/ui/frontend/src/modules/peers/PeerFilters.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type StatusFilter = "all" | "online" | "offline";
|
||||
|
||||
const FILTERS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "online", label: "Online" },
|
||||
{ value: "offline", label: "Offline" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
value: StatusFilter;
|
||||
onChange: (value: StatusFilter) => void;
|
||||
counts: Record<StatusFilter, number>;
|
||||
};
|
||||
|
||||
export const PeerFilters = ({ value, onChange, counts }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex w-full rounded-md border border-nb-gray-900 bg-nb-gray-940 p-0.5"
|
||||
}
|
||||
>
|
||||
{FILTERS.map((f) => {
|
||||
const active = value === f.value;
|
||||
return (
|
||||
<button
|
||||
key={f.value}
|
||||
type={"button"}
|
||||
onClick={() => onChange(f.value)}
|
||||
className={cn(
|
||||
"flex-1 inline-flex items-center justify-center gap-1.5 rounded px-2.5 py-2 text-xs font-medium",
|
||||
"transition-colors duration-150 cursor-default outline-none",
|
||||
active
|
||||
? "bg-nb-gray-800 text-nb-gray-100"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-200",
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[0.65rem] font-mono",
|
||||
active ? "text-nb-gray-300" : "text-nb-gray-500",
|
||||
)}
|
||||
>
|
||||
{counts[f.value]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
client/ui/frontend/src/modules/peers/Peers.tsx
Normal file
73
client/ui/frontend/src/modules/peers/Peers.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { mockPeers } from "./mockPeers";
|
||||
import { PeerFilters, StatusFilter } from "./PeerFilters";
|
||||
import { PeersList } from "./PeersList";
|
||||
|
||||
const isOnline = (status: string) => status === "connected";
|
||||
|
||||
export const Peers = () => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
|
||||
const counts = useMemo<Record<StatusFilter, number>>(() => {
|
||||
const online = mockPeers.filter((p) => isOnline(p.status)).length;
|
||||
return {
|
||||
all: mockPeers.length,
|
||||
online,
|
||||
offline: mockPeers.length - online,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return mockPeers.filter((p) => {
|
||||
if (statusFilter === "online" && !isOnline(p.status)) return false;
|
||||
if (statusFilter === "offline" && isOnline(p.status)) return false;
|
||||
if (q && !p.fqdn.toLowerCase().includes(q) && !p.ip.includes(q)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [search, statusFilter]);
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||
<div className={"flex flex-col gap-3 px-4"}>
|
||||
<SearchInput
|
||||
placeholder={"Search by FQDN or IP…"}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<PeerFilters
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
counts={counts}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea.Root
|
||||
type={"auto"}
|
||||
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<PeersList data={filtered} />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
client/ui/frontend/src/modules/peers/PeersList.tsx
Normal file
51
client/ui/frontend/src/modules/peers/PeersList.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Peer, PeerStatus } from "./types";
|
||||
|
||||
const DOT: Record<PeerStatus, string> = {
|
||||
connected: "bg-green-400",
|
||||
connecting: "bg-yellow-300 animate-pulse-slow",
|
||||
disconnected: "bg-nb-gray-500",
|
||||
};
|
||||
|
||||
export const PeersList = ({ data }: { data: Peer[] }) => {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
|
||||
No peers match the current filters.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={"flex flex-col"}>
|
||||
{data.map((peer) => (
|
||||
<li
|
||||
key={peer.id}
|
||||
className={"flex items-center gap-3 px-4 py-3 min-w-0"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
DOT[peer.status],
|
||||
)}
|
||||
title={peer.status}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
>
|
||||
{peer.fqdn}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"ml-auto text-xs font-mono text-nb-gray-400 shrink-0"
|
||||
}
|
||||
>
|
||||
{peer.ip}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
385
client/ui/frontend/src/modules/peers/mockPeers.ts
Normal file
385
client/ui/frontend/src/modules/peers/mockPeers.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { Peer } from "./types";
|
||||
|
||||
const minutesAgo = (m: number) => new Date(Date.now() - m * 60 * 1000);
|
||||
|
||||
export const mockPeers: Peer[] = [
|
||||
{
|
||||
id: "p-001",
|
||||
fqdn: "alice-laptop.netbird.cloud",
|
||||
ip: "100.64.0.12",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(1),
|
||||
latencyMs: 18,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "srflx",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 84,
|
||||
bytesTx: 1024 * 1024 * 12,
|
||||
endpointLocal: "192.168.1.24:51820",
|
||||
endpointRemote: "203.0.113.45:51820",
|
||||
},
|
||||
{
|
||||
id: "p-002",
|
||||
fqdn: "bob-desktop.netbird.cloud",
|
||||
ip: "100.64.0.21",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(3),
|
||||
latencyMs: 42,
|
||||
relayed: true,
|
||||
relayAddress: "rel.eu-central.netbird.io:443",
|
||||
iceLocalCandidate: "relay",
|
||||
iceRemoteCandidate: "relay",
|
||||
bytesRx: 1024 * 380,
|
||||
bytesTx: 1024 * 940,
|
||||
endpointLocal: "10.0.0.8:51820",
|
||||
endpointRemote: "198.51.100.7:51820",
|
||||
},
|
||||
{
|
||||
id: "p-003",
|
||||
fqdn: "build-runner-01.netbird.cloud",
|
||||
ip: "100.64.0.34",
|
||||
status: "connecting",
|
||||
lastHandshake: minutesAgo(15),
|
||||
latencyMs: 0,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 0,
|
||||
bytesTx: 0,
|
||||
endpointLocal: "192.168.1.45:51820",
|
||||
endpointRemote: "—",
|
||||
},
|
||||
{
|
||||
id: "p-004",
|
||||
fqdn: "carol-phone.netbird.cloud",
|
||||
ip: "100.64.0.55",
|
||||
status: "disconnected",
|
||||
lastHandshake: minutesAgo(620),
|
||||
latencyMs: 0,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "srflx",
|
||||
iceRemoteCandidate: "srflx",
|
||||
bytesRx: 1024 * 1024 * 5,
|
||||
bytesTx: 1024 * 1024 * 2,
|
||||
endpointLocal: "—",
|
||||
endpointRemote: "—",
|
||||
},
|
||||
{
|
||||
id: "p-005",
|
||||
fqdn: "exit-berlin.netbird.cloud",
|
||||
ip: "100.64.0.2",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(0.2),
|
||||
latencyMs: 9,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "srflx",
|
||||
bytesRx: 1024 * 1024 * 1024 * 2,
|
||||
bytesTx: 1024 * 1024 * 512,
|
||||
endpointLocal: "10.10.0.4:51820",
|
||||
endpointRemote: "203.0.113.99:51820",
|
||||
},
|
||||
{
|
||||
id: "p-006",
|
||||
fqdn: "db-replica-eu.netbird.cloud",
|
||||
ip: "100.64.0.78",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(7),
|
||||
latencyMs: 64,
|
||||
relayed: true,
|
||||
relayAddress: "rel.us-east.netbird.io:443",
|
||||
iceLocalCandidate: "relay",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 240,
|
||||
bytesTx: 1024 * 1024 * 90,
|
||||
endpointLocal: "172.16.0.10:51820",
|
||||
endpointRemote: "198.51.100.42:51820",
|
||||
},
|
||||
{
|
||||
id: "p-007",
|
||||
fqdn: "dev-vm-mac.netbird.cloud",
|
||||
ip: "100.64.0.91",
|
||||
status: "disconnected",
|
||||
lastHandshake: minutesAgo(2880),
|
||||
latencyMs: 0,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 0,
|
||||
bytesTx: 0,
|
||||
endpointLocal: "—",
|
||||
endpointRemote: "—",
|
||||
},
|
||||
{
|
||||
id: "p-008",
|
||||
fqdn: "ci-worker-03.netbird.cloud",
|
||||
ip: "100.64.0.103",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(0.5),
|
||||
latencyMs: 27,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "prflx",
|
||||
iceRemoteCandidate: "srflx",
|
||||
bytesRx: 1024 * 1024 * 14,
|
||||
bytesTx: 1024 * 1024 * 3,
|
||||
endpointLocal: "192.168.50.7:51820",
|
||||
endpointRemote: "203.0.113.61:51820",
|
||||
},
|
||||
{
|
||||
id: "p-009",
|
||||
fqdn: "k8s-control-plane.netbird.cloud",
|
||||
ip: "100.64.0.110",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(2),
|
||||
latencyMs: 12,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 410,
|
||||
bytesTx: 1024 * 1024 * 380,
|
||||
endpointLocal: "10.0.1.10:51820",
|
||||
endpointRemote: "10.0.1.11:51820",
|
||||
},
|
||||
{
|
||||
id: "p-010",
|
||||
fqdn: "k8s-worker-01.netbird.cloud",
|
||||
ip: "100.64.0.111",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(2),
|
||||
latencyMs: 14,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 220,
|
||||
bytesTx: 1024 * 1024 * 190,
|
||||
endpointLocal: "10.0.1.20:51820",
|
||||
endpointRemote: "10.0.1.21:51820",
|
||||
},
|
||||
{
|
||||
id: "p-011",
|
||||
fqdn: "k8s-worker-02.netbird.cloud",
|
||||
ip: "100.64.0.112",
|
||||
status: "connecting",
|
||||
lastHandshake: minutesAgo(8),
|
||||
latencyMs: 0,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "srflx",
|
||||
iceRemoteCandidate: "srflx",
|
||||
bytesRx: 0,
|
||||
bytesTx: 0,
|
||||
endpointLocal: "10.0.1.22:51820",
|
||||
endpointRemote: "—",
|
||||
},
|
||||
{
|
||||
id: "p-012",
|
||||
fqdn: "monitoring-prom.netbird.cloud",
|
||||
ip: "100.64.0.130",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(0.3),
|
||||
latencyMs: 22,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "srflx",
|
||||
bytesRx: 1024 * 1024 * 56,
|
||||
bytesTx: 1024 * 1024 * 18,
|
||||
endpointLocal: "10.20.0.5:51820",
|
||||
endpointRemote: "203.0.113.122:51820",
|
||||
},
|
||||
{
|
||||
id: "p-013",
|
||||
fqdn: "grafana.netbird.cloud",
|
||||
ip: "100.64.0.131",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(0.4),
|
||||
latencyMs: 19,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "srflx",
|
||||
bytesRx: 1024 * 1024 * 32,
|
||||
bytesTx: 1024 * 1024 * 8,
|
||||
endpointLocal: "10.20.0.6:51820",
|
||||
endpointRemote: "203.0.113.123:51820",
|
||||
},
|
||||
{
|
||||
id: "p-014",
|
||||
fqdn: "loki-log-aggregator.netbird.cloud",
|
||||
ip: "100.64.0.132",
|
||||
status: "disconnected",
|
||||
lastHandshake: minutesAgo(45),
|
||||
latencyMs: 0,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 12,
|
||||
bytesTx: 1024 * 1024 * 4,
|
||||
endpointLocal: "—",
|
||||
endpointRemote: "—",
|
||||
},
|
||||
{
|
||||
id: "p-015",
|
||||
fqdn: "dave-laptop.netbird.cloud",
|
||||
ip: "100.64.0.140",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(1),
|
||||
latencyMs: 38,
|
||||
relayed: true,
|
||||
relayAddress: "rel.eu-west.netbird.io:443",
|
||||
iceLocalCandidate: "relay",
|
||||
iceRemoteCandidate: "relay",
|
||||
bytesRx: 1024 * 720,
|
||||
bytesTx: 1024 * 410,
|
||||
endpointLocal: "192.168.43.21:51820",
|
||||
endpointRemote: "198.51.100.88:51820",
|
||||
},
|
||||
{
|
||||
id: "p-016",
|
||||
fqdn: "eve-iphone.netbird.cloud",
|
||||
ip: "100.64.0.150",
|
||||
status: "connecting",
|
||||
lastHandshake: minutesAgo(20),
|
||||
latencyMs: 0,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "srflx",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 0,
|
||||
bytesTx: 0,
|
||||
endpointLocal: "—",
|
||||
endpointRemote: "—",
|
||||
},
|
||||
{
|
||||
id: "p-017",
|
||||
fqdn: "frank-windows.netbird.cloud",
|
||||
ip: "100.64.0.155",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(4),
|
||||
latencyMs: 76,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "srflx",
|
||||
iceRemoteCandidate: "srflx",
|
||||
bytesRx: 1024 * 1024 * 6,
|
||||
bytesTx: 1024 * 1024 * 2,
|
||||
endpointLocal: "192.168.1.55:51820",
|
||||
endpointRemote: "203.0.113.200:51820",
|
||||
},
|
||||
{
|
||||
id: "p-018",
|
||||
fqdn: "exit-frankfurt.netbird.cloud",
|
||||
ip: "100.64.0.3",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(0.1),
|
||||
latencyMs: 6,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 1024 * 5,
|
||||
bytesTx: 1024 * 1024 * 1024 * 1,
|
||||
endpointLocal: "10.10.0.5:51820",
|
||||
endpointRemote: "203.0.113.150:51820",
|
||||
},
|
||||
{
|
||||
id: "p-019",
|
||||
fqdn: "exit-singapore.netbird.cloud",
|
||||
ip: "100.64.0.4",
|
||||
status: "disconnected",
|
||||
lastHandshake: minutesAgo(180),
|
||||
latencyMs: 0,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 880,
|
||||
bytesTx: 1024 * 1024 * 220,
|
||||
endpointLocal: "—",
|
||||
endpointRemote: "—",
|
||||
},
|
||||
{
|
||||
id: "p-020",
|
||||
fqdn: "nas-home.netbird.cloud",
|
||||
ip: "100.64.0.180",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(0.7),
|
||||
latencyMs: 31,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "srflx",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 1024 * 3,
|
||||
bytesTx: 1024 * 1024 * 480,
|
||||
endpointLocal: "192.168.0.50:51820",
|
||||
endpointRemote: "203.0.113.45:51820",
|
||||
},
|
||||
{
|
||||
id: "p-021",
|
||||
fqdn: "raspberrypi-iot.netbird.cloud",
|
||||
ip: "100.64.0.181",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(5),
|
||||
latencyMs: 54,
|
||||
relayed: true,
|
||||
relayAddress: "rel.eu-central.netbird.io:443",
|
||||
iceLocalCandidate: "relay",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 240,
|
||||
bytesTx: 1024 * 110,
|
||||
endpointLocal: "192.168.0.121:51820",
|
||||
endpointRemote: "198.51.100.42:51820",
|
||||
},
|
||||
{
|
||||
id: "p-022",
|
||||
fqdn: "staging-api.netbird.cloud",
|
||||
ip: "100.64.0.200",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(0.2),
|
||||
latencyMs: 16,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 92,
|
||||
bytesTx: 1024 * 1024 * 140,
|
||||
endpointLocal: "10.30.0.10:51820",
|
||||
endpointRemote: "10.30.0.11:51820",
|
||||
},
|
||||
{
|
||||
id: "p-023",
|
||||
fqdn: "prod-api-eu.netbird.cloud",
|
||||
ip: "100.64.0.201",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(0.1),
|
||||
latencyMs: 8,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 1024 * 1024 * 1024 * 12,
|
||||
bytesTx: 1024 * 1024 * 1024 * 3,
|
||||
endpointLocal: "10.40.0.10:51820",
|
||||
endpointRemote: "10.40.0.11:51820",
|
||||
},
|
||||
{
|
||||
id: "p-024",
|
||||
fqdn: "prod-api-us.netbird.cloud",
|
||||
ip: "100.64.0.202",
|
||||
status: "connected",
|
||||
lastHandshake: minutesAgo(0.1),
|
||||
latencyMs: 92,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "srflx",
|
||||
iceRemoteCandidate: "srflx",
|
||||
bytesRx: 1024 * 1024 * 1024 * 8,
|
||||
bytesTx: 1024 * 1024 * 1024 * 2,
|
||||
endpointLocal: "10.50.0.10:51820",
|
||||
endpointRemote: "203.0.113.210:51820",
|
||||
},
|
||||
{
|
||||
id: "p-025",
|
||||
fqdn: "old-jenkins.netbird.cloud",
|
||||
ip: "100.64.0.220",
|
||||
status: "disconnected",
|
||||
lastHandshake: minutesAgo(8640),
|
||||
latencyMs: 0,
|
||||
relayed: false,
|
||||
iceLocalCandidate: "host",
|
||||
iceRemoteCandidate: "host",
|
||||
bytesRx: 0,
|
||||
bytesTx: 0,
|
||||
endpointLocal: "—",
|
||||
endpointRemote: "—",
|
||||
},
|
||||
];
|
||||
20
client/ui/frontend/src/modules/peers/types.ts
Normal file
20
client/ui/frontend/src/modules/peers/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type PeerStatus = "connected" | "connecting" | "disconnected";
|
||||
|
||||
export type IceCandidateType = "host" | "srflx" | "relay" | "prflx";
|
||||
|
||||
export type Peer = {
|
||||
id: string;
|
||||
fqdn: string;
|
||||
ip: string;
|
||||
status: PeerStatus;
|
||||
lastHandshake: Date;
|
||||
latencyMs: number;
|
||||
relayed: boolean;
|
||||
relayAddress?: string;
|
||||
iceLocalCandidate: IceCandidateType;
|
||||
iceRemoteCandidate: IceCandidateType;
|
||||
bytesRx: number;
|
||||
bytesTx: number;
|
||||
endpointLocal: string;
|
||||
endpointRemote: string;
|
||||
};
|
||||
76
client/ui/frontend/src/modules/profile/ProfileContext.tsx
Normal file
76
client/ui/frontend/src/modules/profile/ProfileContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Profiles as ProfilesSvc } from "@bindings/services";
|
||||
|
||||
type ProfileContextValue = {
|
||||
username: string;
|
||||
activeProfile: string;
|
||||
loaded: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
switchProfile: (name: 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 [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const u = await ProfilesSvc.Username();
|
||||
const active = await ProfilesSvc.GetActive();
|
||||
setUsername(u);
|
||||
setActiveProfile(active.profileName || "default");
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const switchProfile = useCallback(
|
||||
async (name: string) => {
|
||||
await ProfilesSvc.Switch({ profileName: name, username });
|
||||
setActiveProfile(name);
|
||||
},
|
||||
[username],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider
|
||||
value={{
|
||||
username,
|
||||
activeProfile,
|
||||
loaded,
|
||||
error,
|
||||
refresh,
|
||||
switchProfile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ProfileContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||
import { SwitchItem } from "@/components/SwitchItem";
|
||||
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
import { ManagementMode } from "@/modules/settings/useManagementUrl.ts";
|
||||
|
||||
type Props = {
|
||||
value: ManagementMode;
|
||||
onChange: (mode: ManagementMode) => void;
|
||||
};
|
||||
|
||||
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
|
||||
return (
|
||||
<SwitchItemGroup value={value} onChange={(v) => onChange(v as ManagementMode)}>
|
||||
<SwitchItem value={ManagementMode.Cloud}>
|
||||
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
|
||||
Cloud
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted}>Self-hosted</SwitchItem>
|
||||
</SwitchItemGroup>
|
||||
);
|
||||
};
|
||||
71
client/ui/frontend/src/modules/settings/Settings.tsx
Normal file
71
client/ui/frontend/src/modules/settings/Settings.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
|
||||
import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
||||
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
|
||||
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||
|
||||
export const Settings = () => {
|
||||
const [active, setActive] = useState("general");
|
||||
|
||||
return (
|
||||
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
|
||||
<SettingsNavigationTriggers />
|
||||
<MainRightSide>
|
||||
<ScrollArea.Root
|
||||
type={"auto"}
|
||||
className={"flex-1 min-h-0 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<div className={"py-8 px-7"}>
|
||||
<SettingsProvider>
|
||||
<VerticalTabs.Content value={"general"}>
|
||||
<SettingsGeneral />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"network"}>
|
||||
<SettingsNetwork />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"security"}>
|
||||
<SettingsSecurity />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"ssh"}>
|
||||
<SettingsSSH />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"advanced"}>
|
||||
<SettingsAdvanced />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"troubleshooting"}>
|
||||
<SettingsTroubleshooting />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"about"}>
|
||||
<SettingsAbout />
|
||||
</VerticalTabs.Content>
|
||||
</SettingsProvider>
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</MainRightSide>
|
||||
</VerticalTabs>
|
||||
);
|
||||
};
|
||||
66
client/ui/frontend/src/modules/settings/SettingsAbout.tsx
Normal file
66
client/ui/frontend/src/modules/settings/SettingsAbout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
import pkg from "../../../package.json";
|
||||
import { useStatus } from "@/hooks/useStatus";
|
||||
import { NetBirdVersionCard } from "@/components/NetBirdVersionCard";
|
||||
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
|
||||
|
||||
const LEGAL_LINKS: { label: string; url: string }[] = [
|
||||
{ label: "Imprint", url: "https://netbird.io/imprint" },
|
||||
{ label: "Privacy", url: "https://netbird.io/privacy" },
|
||||
{ label: "CLA", url: "https://netbird.io/cla" },
|
||||
{ label: "Terms of Service", url: "https://netbird.io/terms" },
|
||||
];
|
||||
|
||||
function openUrl(url: string) {
|
||||
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||
}
|
||||
|
||||
export function SettingsAbout() {
|
||||
const { status } = useStatus();
|
||||
const guiVersion = pkg.version;
|
||||
const daemonVersion = status?.daemonVersion ?? "—";
|
||||
|
||||
const handleVersionClick = useAccentTrigger();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<p
|
||||
className={"text-sm font-semibold text-nb-gray-100 cursor-default select-none"}
|
||||
onClick={handleVersionClick}
|
||||
>
|
||||
NetBird Client v{daemonVersion}
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-300"}>GUI v{guiVersion}</p>
|
||||
</div>
|
||||
|
||||
<NetBirdVersionCard />
|
||||
|
||||
<p className={"text-sm text-nb-gray-300 text-center"}>
|
||||
© {new Date().getFullYear()} NetBird. All Rights Reserved.
|
||||
</p>
|
||||
<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"}
|
||||
onClick={() => openUrl(link.url)}
|
||||
className={
|
||||
"decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
|
||||
}
|
||||
>
|
||||
{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 }: { 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 = window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
window.setTimeout(onDone, 500);
|
||||
}, 9000);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.clearTimeout(timeout);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [onDone]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 bg-black/5 transition-opacity duration-500 pointer-events-none ${visible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<canvas ref={canvasRef} className={"block"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx
Normal file
103
client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@/components/Button";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsAdvanced() {
|
||||
const { config, saveFields } = useSettings();
|
||||
|
||||
const [values, setValues] = useState({
|
||||
interfaceName: config.interfaceName,
|
||||
wireguardPort: config.wireguardPort,
|
||||
mtu: config.mtu,
|
||||
preSharedKey: config.preSharedKey,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const hasChanges =
|
||||
values.interfaceName !== config.interfaceName ||
|
||||
values.wireguardPort !== config.wireguardPort ||
|
||||
values.mtu !== config.mtu ||
|
||||
values.preSharedKey !== config.preSharedKey;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveFields(values);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Interface"}>
|
||||
<Input
|
||||
label={"Name"}
|
||||
value={values.interfaceName}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({ ...v, interfaceName: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<div className={"grid grid-cols-2 gap-4"}>
|
||||
<Input
|
||||
label={"Port"}
|
||||
type={"number"}
|
||||
value={values.wireguardPort}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
wireguardPort: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={"MTU"}
|
||||
type={"number"}
|
||||
value={values.mtu}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({ ...v, mtu: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Security"}>
|
||||
<div>
|
||||
<Label as={"div"}>Pre-shared Key</Label>
|
||||
<HelpText>
|
||||
Optional WireGuard PSK for extra symmetric encryption. Not the same as a
|
||||
NetBird Setup Key. You will only communicate with peers that use the same
|
||||
pre-shared key.
|
||||
</HelpText>
|
||||
<Input
|
||||
type={"password"}
|
||||
showPasswordToggle
|
||||
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
||||
value={values.preSharedKey}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({ ...v, preSharedKey: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div className={"w-full flex justify-end px-8 py-5 border-t border-nb-gray-910"}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
disabled={!hasChanges || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
client/ui/frontend/src/modules/settings/SettingsContext.tsx
Normal file
167
client/ui/frontend/src/modules/settings/SettingsContext.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import type { Config } from "@bindings/services/models.js";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
type SettingsContextValue = {
|
||||
config: Config;
|
||||
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>) => Promise<void>;
|
||||
saveNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
const SettingsContext = createContext<SettingsContextValue | null>(null);
|
||||
|
||||
export const useSettings = () => {
|
||||
const ctx = useContext(SettingsContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useSettings must be used inside SettingsProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const useSettingsState = () => {
|
||||
const { username, activeProfile, loaded: profileLoaded } = useProfile();
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileLoaded || !activeProfile) return;
|
||||
(async () => {
|
||||
try {
|
||||
const c = await SettingsSvc.GetConfig({
|
||||
profileName: activeProfile,
|
||||
username,
|
||||
});
|
||||
setConfig(c);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
})();
|
||||
}, [profileLoaded, activeProfile, username]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (next: Config) => {
|
||||
// The daemon masks an existing PSK as "**********" in GetConfig.
|
||||
// Sending the mask back round-trips it into the saved config and
|
||||
// wgtypes.ParseKey fails on the next connect. Drop the mask so
|
||||
// unrelated toggles don't corrupt the stored PSK.
|
||||
const { preSharedKey, ...rest } = next;
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
...rest,
|
||||
...(preSharedKey === "**********" ? {} : { preSharedKey }),
|
||||
profileName: activeProfile,
|
||||
username,
|
||||
});
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
},
|
||||
[activeProfile, username],
|
||||
);
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof Config>(k: K, v: Config[K]) => {
|
||||
setConfig((c) => {
|
||||
if (!c) return c;
|
||||
const next = { ...c, [k]: v };
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
void save(next);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const saveNow = useCallback(async () => {
|
||||
if (!config) return;
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
await save(config);
|
||||
}, [config, save]);
|
||||
|
||||
const saveField = useCallback(
|
||||
async <K extends keyof Config>(k: K, v: Config[K]) => {
|
||||
if (!config) return;
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
const next = { ...config, [k]: v };
|
||||
setConfig(next);
|
||||
await save(next);
|
||||
},
|
||||
[config, save],
|
||||
);
|
||||
|
||||
const saveFields = useCallback(
|
||||
async (partial: Partial<Config>) => {
|
||||
if (!config) return;
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
const next = { ...config, ...partial };
|
||||
setConfig(next);
|
||||
await save(next);
|
||||
},
|
||||
[config, save],
|
||||
);
|
||||
|
||||
return { config, error, setField, saveField, saveFields, saveNow };
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, error, setField, saveField, saveFields, saveNow } = useSettingsState();
|
||||
|
||||
// TODO: Better displaying of errors
|
||||
return (
|
||||
<>
|
||||
{error && <p className={"pb-6 text-sm text-red-500"}>{error}</p>}
|
||||
<div className={"flex-1 min-h-0 overflow-y-auto"}>
|
||||
{!config ? (
|
||||
<SkeletonSettings />
|
||||
) : (
|
||||
<SettingsContext.Provider
|
||||
value={{
|
||||
config,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
saveNow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
84
client/ui/frontend/src/modules/settings/SettingsGeneral.tsx
Normal file
84
client/ui/frontend/src/modules/settings/SettingsGeneral.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/Button";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { config, setField } = useSettings();
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMode = useRef(mode);
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevMode.current === ManagementMode.Cloud &&
|
||||
mode === ManagementMode.SelfHosted
|
||||
) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
prevMode.current = mode;
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"General"}>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableAutoConnect}
|
||||
onChange={(v) => setField("disableAutoConnect", !v)}
|
||||
label={"Connect on Startup"}
|
||||
helpText={"Automatically establish a connection when the service starts."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableNotifications}
|
||||
onChange={(v) => setField("disableNotifications", !v)}
|
||||
label={"Desktop Notifications"}
|
||||
helpText={"Show desktop notifications for new updates and connection events."}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Connection"}>
|
||||
<div>
|
||||
<div className={"flex items-start gap-3"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<Label as={"div"}>Management Server</Label>
|
||||
<HelpText>
|
||||
Connect to NetBird Cloud or your own self-hosted management server.
|
||||
Changes will reconnect the client.
|
||||
</HelpText>
|
||||
</div>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} />
|
||||
</div>
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<div className={"flex items-start gap-3 mt-2"}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={displayUrl}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={"https://netbird.selfhosted.com:443"}
|
||||
error={
|
||||
showError
|
||||
? "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
disabled={!canSave}
|
||||
onClick={() => save()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import {
|
||||
BoltIcon,
|
||||
InfoIcon,
|
||||
LifeBuoyIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SquareTerminalIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const SettingsNavigationTriggers = () => {
|
||||
return (
|
||||
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.Trigger
|
||||
value={"general"}
|
||||
icon={SlidersHorizontalIcon}
|
||||
title={"General"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"network"}
|
||||
icon={NetworkIcon}
|
||||
title={"Network"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"security"}
|
||||
icon={ShieldIcon}
|
||||
title={"Security"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"ssh"}
|
||||
icon={SquareTerminalIcon}
|
||||
title={"SSH"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"advanced"}
|
||||
icon={BoltIcon}
|
||||
title={"Advanced"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"troubleshooting"}
|
||||
icon={LifeBuoyIcon}
|
||||
title={"Troubleshooting"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"about"}
|
||||
icon={InfoIcon}
|
||||
title={"About"}
|
||||
/>
|
||||
</VerticalTabs.List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
client/ui/frontend/src/modules/settings/SettingsNetwork.tsx
Normal file
51
client/ui/frontend/src/modules/settings/SettingsNetwork.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsNetwork() {
|
||||
const { config, setField } = useSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Connectivity"}>
|
||||
<FancyToggleSwitch
|
||||
value={config.lazyConnectionEnabled}
|
||||
onChange={(v) => setField("lazyConnectionEnabled", v)}
|
||||
label={"Lazy Connections"}
|
||||
helpText={
|
||||
"Instead of maintaining always-on connections, NetBird activates them on-demand based on activity or signaling."
|
||||
}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.networkMonitor}
|
||||
onChange={(v) => setField("networkMonitor", v)}
|
||||
label={"Reconnect on Network Change"}
|
||||
helpText={
|
||||
"Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep."
|
||||
}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Routing & DNS"}>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableDns}
|
||||
onChange={(v) => setField("disableDns", !v)}
|
||||
label={"Enable DNS"}
|
||||
helpText={"Apply NetBird-managed DNS settings to the host resolver."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableClientRoutes}
|
||||
onChange={(v) => setField("disableClientRoutes", !v)}
|
||||
label={"Enable Client Routes"}
|
||||
helpText={"Accept routes from other peers to reach their networks."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableServerRoutes}
|
||||
onChange={(v) => setField("disableServerRoutes", !v)}
|
||||
label={"Enable Server Routes"}
|
||||
helpText={"Advertise this host's local routes to other peers."}
|
||||
/>
|
||||
</SectionGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
client/ui/frontend/src/modules/settings/SettingsSSH.tsx
Normal file
124
client/ui/frontend/src/modules/settings/SettingsSSH.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { config, setField } = useSettings();
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
// Keep the local input in sync when the config changes from elsewhere
|
||||
useEffect(() => {
|
||||
setJwtTtlInput(String(config.sshJwtCacheTtl));
|
||||
}, [config.sshJwtCacheTtl]);
|
||||
|
||||
const handleJwtTtlChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
setJwtTtlInput(v);
|
||||
if (v === "") return;
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n >= 0) {
|
||||
setField("sshJwtCacheTtl", n);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJwtTtlBlur = () => {
|
||||
if (jwtTtlInput === "") {
|
||||
setJwtTtlInput("0");
|
||||
setField("sshJwtCacheTtl", 0);
|
||||
return;
|
||||
}
|
||||
const n = Number(jwtTtlInput);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
setJwtTtlInput(String(config.sshJwtCacheTtl));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Server"}>
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
label={"Enable SSH Server"}
|
||||
helpText={
|
||||
"Run the NetBird SSH server on this host so other peers can connect to it."
|
||||
}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Capabilities"} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
label={"Allow Root Login"}
|
||||
helpText={
|
||||
"Let peers sign in as the root user. Disable to require a non-privileged account."
|
||||
}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshSftp}
|
||||
onChange={(v) => setField("enableSshSftp", v)}
|
||||
label={"Allow SFTP"}
|
||||
helpText={"Transfer files securely using native SFTP or SCP clients."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshLocalPortForwarding}
|
||||
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
|
||||
label={"Local Port Forwarding"}
|
||||
helpText={
|
||||
"Let connecting peers tunnel local ports to services reachable from this host."
|
||||
}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRemotePortForwarding}
|
||||
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
|
||||
label={"Remote Port Forwarding"}
|
||||
helpText={
|
||||
"Let connecting peers expose ports on this host back to their own machine."
|
||||
}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Authentication"} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
label={"Enable JWT Authentication"}
|
||||
helpText={
|
||||
"Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available."
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-6 justify-between",
|
||||
config.disableSshAuth && "opacity-50 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>JWT Cache TTL</Label>
|
||||
<HelpText margin={false}>
|
||||
How long this client caches a JWT before prompting again on outgoing SSH
|
||||
connections. Set to 0 to disable caching and authenticate on every
|
||||
connection.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
<Input
|
||||
type={"number"}
|
||||
min={0}
|
||||
value={jwtTtlInput}
|
||||
onChange={handleJwtTtlChange}
|
||||
onBlur={handleJwtTtlBlur}
|
||||
customSuffix={"Second(s)"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
client/ui/frontend/src/modules/settings/SettingsSection.tsx
Normal file
19
client/ui/frontend/src/modules/settings/SettingsSection.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export const SectionGroup = ({
|
||||
title,
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<section className={cn("mb-8 last:mb-1 px-1", disabled && "opacity-30 pointer-events-none")}>
|
||||
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
|
||||
{title}
|
||||
</h2>
|
||||
<div className={"flex flex-col gap-5"}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
52
client/ui/frontend/src/modules/settings/SettingsSecurity.tsx
Normal file
52
client/ui/frontend/src/modules/settings/SettingsSecurity.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsSecurity() {
|
||||
const { config, setField } = useSettings();
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Firewall"}>
|
||||
<FancyToggleSwitch
|
||||
value={config.blockInbound}
|
||||
onChange={(v) => setField("blockInbound", v)}
|
||||
label={"Block Inbound Traffic"}
|
||||
helpText={
|
||||
"Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected."
|
||||
}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.blockLanAccess}
|
||||
onChange={(v) => setField("blockLanAccess", v)}
|
||||
label={"Block LAN Access"}
|
||||
helpText={
|
||||
"Prevent peers from reaching your local network or its devices when this device routes their traffic."
|
||||
}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Encryption"}>
|
||||
<FancyToggleSwitch
|
||||
value={config.rosenpassEnabled}
|
||||
onChange={(v) => {
|
||||
setField("rosenpassEnabled", v);
|
||||
if (!v) setField("rosenpassPermissive", false);
|
||||
}}
|
||||
label={"Enable Quantum-Resistance"}
|
||||
helpText={
|
||||
"Add a post-quantum key exchange via Rosenpass on top of WireGuard®."
|
||||
}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.rosenpassPermissive}
|
||||
onChange={(v) => setField("rosenpassPermissive", v)}
|
||||
label={"Enable Permissive Mode"}
|
||||
helpText={
|
||||
"Allow connections to peers without quantum-resistance support."
|
||||
}
|
||||
disabled={!config.rosenpassEnabled}
|
||||
/>
|
||||
</SectionGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import { Button } from "@/components/Button";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import HelpText from "@/components/HelpText.tsx";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import { StatusPanel } from "@/components/StatusPanel";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { DebugStage } from "@/modules/debug-bundle/useDebugBundle.ts";
|
||||
import { useDebugBundleContext } from "@/modules/debug-bundle/useDebugBundleContext.ts";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
|
||||
export function SettingsTroubleshooting() {
|
||||
const {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
systemInfo,
|
||||
setSystemInfo,
|
||||
upload,
|
||||
setUpload,
|
||||
trace,
|
||||
setTrace,
|
||||
traceMinutes,
|
||||
setTraceMinutes,
|
||||
run,
|
||||
stage,
|
||||
cancel,
|
||||
reset,
|
||||
} = useDebugBundleContext();
|
||||
|
||||
if (stage.kind === "done" || stage.kind === "error") {
|
||||
return <ResultSection stage={stage} onClose={reset} />;
|
||||
}
|
||||
if (stage.kind !== "idle") {
|
||||
return <ProgressSection stage={stage} onCancel={cancel} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionGroup title={"Debug bundle"}>
|
||||
<HelpText className={"-mt-2 mb-2"}>
|
||||
A debug bundle helps NetBird support investigate connection problems. <br /> It's a
|
||||
.zip file with logs, system details and debug information from your device.
|
||||
</HelpText>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label={"Anonymize Sensitive Information"}
|
||||
helpText={"Hides public IP addresses and non-NetBird domains from logs."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
label={"Include System Information"}
|
||||
helpText={"Include OS, kernel, network interfaces, and routing tables."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={upload}
|
||||
onChange={setUpload}
|
||||
label={"Upload Bundle to NetBird Servers"}
|
||||
helpText={
|
||||
"Securely uploads the bundle and returns an upload key. Share the key with NetBird support over GitHub or Slack instead of attaching the file directly."
|
||||
}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={trace}
|
||||
onChange={setTrace}
|
||||
label={"Capture Trace Logs"}
|
||||
helpText={
|
||||
"Raises logging to TRACE and cycles NetBird up and down to capture connection logs. The previous level is restored after the bundle is built."
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-6 justify-between",
|
||||
!trace && "opacity-50 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>Capture Duration</Label>
|
||||
<HelpText margin={false}>
|
||||
How long to capture trace logs before generating the bundle.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
<Input
|
||||
type={"number"}
|
||||
min={1}
|
||||
max={30}
|
||||
value={traceMinutes}
|
||||
onChange={(e) =>
|
||||
setTraceMinutes(Math.max(1, Math.min(30, Number(e.target.value) || 1)))
|
||||
}
|
||||
customSuffix={"Minute(s)"}
|
||||
disabled={!trace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BottomBar>
|
||||
<Button variant={"primary"} size={"md"} onClick={run}>
|
||||
Create Bundle
|
||||
</Button>
|
||||
</BottomBar>
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
|
||||
const cancelling = stage.kind === "cancelling";
|
||||
return (
|
||||
<StatusPanel
|
||||
variant={"loading"}
|
||||
title={stageLabel(stage)}
|
||||
description={
|
||||
"Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes."
|
||||
}
|
||||
actions={
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onCancel} disabled={cancelling}>
|
||||
{cancelling ? "Cancelling…" : "Cancel"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultSection({
|
||||
stage,
|
||||
onClose,
|
||||
}: {
|
||||
stage: Extract<DebugStage, { kind: "done" } | { kind: "error" }>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (stage.kind === "error") {
|
||||
return (
|
||||
<StatusPanel
|
||||
variant={"error"}
|
||||
title={"Bundle failed"}
|
||||
description={stage.message}
|
||||
actions={
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={onClose} />;
|
||||
}
|
||||
|
||||
function DoneResult({
|
||||
result,
|
||||
uploaded,
|
||||
onClose,
|
||||
}: {
|
||||
result: DebugBundleResult;
|
||||
uploaded: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const showKey = uploaded && Boolean(result.uploadedKey);
|
||||
const uploadFailed = uploaded && !result.uploadedKey;
|
||||
const onRevealPath = () => {
|
||||
if (!result.path) return;
|
||||
void DebugSvc.RevealFile(result.path).catch(() => {});
|
||||
};
|
||||
return (
|
||||
<StatusPanel
|
||||
variant={"success"}
|
||||
title={showKey ? "Debug bundle successfully uploaded!" : "Bundle saved"}
|
||||
description={
|
||||
showKey
|
||||
? "Share the upload key below with NetBird support. A local copy was also saved on your device."
|
||||
: "Your debug bundle has been saved locally."
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{showKey ? (
|
||||
<Button variant={"primary"} size={"xs"} copy={result.uploadedKey}>
|
||||
Copy Key
|
||||
</Button>
|
||||
) : (
|
||||
result.path && (
|
||||
<Button variant={"primary"} size={"xs"} onClick={onRevealPath}>
|
||||
<FolderOpen size={12} />
|
||||
Open Folder
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={"w-full max-w-xs mx-auto flex flex-col gap-3"}>
|
||||
{showKey && <Input value={result.uploadedKey} readOnly copy />}
|
||||
|
||||
{result.path && !showKey && (
|
||||
<Input
|
||||
value={result.path}
|
||||
readOnly
|
||||
customSuffix={
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onRevealPath}
|
||||
className={"pointer-events-auto hover:text-white transition-all"}
|
||||
aria-label={"Open file location"}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{uploadFailed && (
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
}
|
||||
>
|
||||
Upload failed
|
||||
{result.uploadFailureReason ? `: ${result.uploadFailureReason}` : "."} The
|
||||
bundle is still saved locally.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StatusPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomBar({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div
|
||||
className={
|
||||
"w-full flex justify-end gap-3 px-8 py-5 border-t border-nb-gray-900 bg-nb-gray-935"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stageLabel = (stage: DebugStage): string => {
|
||||
switch (stage.kind) {
|
||||
case "preparing-trace":
|
||||
return "Switching to trace logging…";
|
||||
case "reconnecting":
|
||||
return "Reconnecting NetBird…";
|
||||
case "capturing": {
|
||||
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
return `Capturing logs — ${fmt(
|
||||
stage.totalSec - stage.remainingSec,
|
||||
)} / ${fmt(stage.totalSec)}`;
|
||||
}
|
||||
case "restoring-level":
|
||||
return "Restoring previous log level…";
|
||||
case "bundling":
|
||||
return "Generating debug bundle…";
|
||||
case "uploading":
|
||||
return "Uploading to NetBird…";
|
||||
case "cancelling":
|
||||
return "Cancelling…";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
86
client/ui/frontend/src/modules/settings/useManagementUrl.ts
Normal file
86
client/ui/frontend/src/modules/settings/useManagementUrl.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export enum ManagementMode {
|
||||
Cloud = "cloud",
|
||||
SelfHosted = "selfhosted",
|
||||
}
|
||||
|
||||
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
|
||||
|
||||
function normalizeManagementUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
const URL_PATTERN = new RegExp(
|
||||
"^(https?:\\/\\/)?" +
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
|
||||
"((\\d{1,3}\\.){3}\\d{1,3}))" +
|
||||
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" +
|
||||
"(\\?[;&a-z\\d%_.~+=-]*)?" +
|
||||
"(\\#[-a-z\\d_]*)?$",
|
||||
"i",
|
||||
);
|
||||
|
||||
function isValidManagementUrl(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
return URL_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
function modeFromUrl(url: string): ManagementMode {
|
||||
return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted;
|
||||
}
|
||||
|
||||
export function useManagementUrl() {
|
||||
const { config, saveField } = useSettings();
|
||||
const [mode, setModeState] = useState<ManagementMode>(
|
||||
modeFromUrl(config.managementUrl),
|
||||
);
|
||||
const [url, setUrl] = useState(
|
||||
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setModeState(modeFromUrl(config.managementUrl));
|
||||
if (config.managementUrl !== CLOUD_MANAGEMENT_URL) {
|
||||
setUrl(config.managementUrl);
|
||||
}
|
||||
}, [config.managementUrl]);
|
||||
|
||||
const setMode = (next: ManagementMode) => {
|
||||
setModeState(next);
|
||||
if (
|
||||
next === ManagementMode.Cloud &&
|
||||
config.managementUrl !== CLOUD_MANAGEMENT_URL
|
||||
) {
|
||||
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedUrl = normalizeManagementUrl(url);
|
||||
const urlValid = isValidManagementUrl(url);
|
||||
const targetUrl =
|
||||
mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
|
||||
const dirty = targetUrl !== config.managementUrl;
|
||||
const showError =
|
||||
mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
|
||||
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
|
||||
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
|
||||
|
||||
const save = () => saveField("managementUrl", targetUrl);
|
||||
|
||||
return {
|
||||
mode,
|
||||
setMode,
|
||||
url,
|
||||
setUrl,
|
||||
displayUrl,
|
||||
showError,
|
||||
canSave,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
export const SkeletonSettings = () => {
|
||||
return (
|
||||
<div className={"gap-6 flex flex-col"}>
|
||||
<div>
|
||||
<Skeleton width={100} height={16} className={"mb-4"} />
|
||||
<div>
|
||||
<Skeleton width={100} height={14} />
|
||||
<Skeleton width={400} height={10} />
|
||||
</div>
|
||||
<div className={"mt-3"}>
|
||||
<Skeleton width={100} height={14} />
|
||||
<Skeleton width={400} height={10} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton width={100} height={16} className={"mb-4"} />
|
||||
<div>
|
||||
<Skeleton width={100} height={14} />
|
||||
<Skeleton width={400} height={10} />
|
||||
<Skeleton width={300} height={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
110
client/ui/frontend/src/pages/Debug.tsx
Normal file
110
client/ui/frontend/src/pages/Debug.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Debug.tsx
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
========
|
||||
import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Debug.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
import { Switch } from "../components/Switch";
|
||||
import { Card } from "../components/Card";
|
||||
|
||||
export default function Debug() {
|
||||
const [anonymize, setAnonymize] = useState(true);
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(false);
|
||||
const [uploadUrl, setUploadUrl] = useState("");
|
||||
const [logFiles, setLogFiles] = useState(0);
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<DebugBundleResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await DebugSvc.Bundle({
|
||||
anonymize,
|
||||
systemInfo,
|
||||
uploadUrl: upload ? uploadUrl : "",
|
||||
logFileCount: logFiles,
|
||||
});
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<h1 className="text-xl font-semibold">Debug bundle</h1>
|
||||
|
||||
<Card className="space-y-4">
|
||||
<Switch
|
||||
checked={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label="Anonymize"
|
||||
description="Replace IPs and identifiers in the bundle."
|
||||
/>
|
||||
<Switch
|
||||
checked={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
label="Include system information"
|
||||
/>
|
||||
<Switch
|
||||
checked={upload}
|
||||
onChange={setUpload}
|
||||
label="Upload on create"
|
||||
/>
|
||||
{upload && (
|
||||
<Input
|
||||
label="Upload URL"
|
||||
value={uploadUrl}
|
||||
onChange={(e) => setUploadUrl(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label="Log file count"
|
||||
type="number"
|
||||
value={logFiles}
|
||||
onChange={(e) => setLogFiles(Number(e.target.value))}
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<Button onClick={run} disabled={running}>
|
||||
{running ? "Generating…" : "Create bundle"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
{result.path && (
|
||||
<p className="text-sm">
|
||||
<span className="text-nb-gray-500">Path:</span>{" "}
|
||||
<span className="font-mono">{result.path}</span>
|
||||
</p>
|
||||
)}
|
||||
{result.uploadedKey && (
|
||||
<p className="text-sm">
|
||||
<span className="text-nb-gray-500">Uploaded key:</span>{" "}
|
||||
<span className="font-mono">{result.uploadedKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{result.uploadFailureReason && (
|
||||
<p className="text-sm text-red-500">
|
||||
Upload failed: {result.uploadFailureReason}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
client/ui/frontend/src/pages/Login.tsx
Normal file
159
client/ui/frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ExternalLink, Loader2, AlertTriangle, X, RotateCcw } from "lucide-react";
|
||||
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import { Button } from "../components/Button";
|
||||
|
||||
type Phase = "starting" | "browser" | "connecting" | "error";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [phase, setPhase] = useState<Phase>("starting");
|
||||
const [verificationUri, setVerificationUri] = useState<string>("");
|
||||
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||
// attempt is bumped every time the user asks for a fresh start, which
|
||||
// re-arms the useEffect below so the daemon's Login RPC is dialed again.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false;
|
||||
setPhase("starting");
|
||||
setVerificationUri("");
|
||||
setErrorMsg("");
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await Connection.Login({
|
||||
profileName: "",
|
||||
username: "",
|
||||
managementUrl: "",
|
||||
setupKey: "",
|
||||
preSharedKey: "",
|
||||
hostname: "",
|
||||
hint: "",
|
||||
});
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
if (result.needsSsoLogin) {
|
||||
const uri = result.verificationUriComplete || result.verificationUri;
|
||||
setVerificationUri(uri);
|
||||
setPhase("browser");
|
||||
if (uri) Connection.OpenURL(uri).catch(console.error);
|
||||
|
||||
await Connection.WaitSSOLogin({
|
||||
userCode: result.userCode,
|
||||
hostname: "",
|
||||
});
|
||||
if (cancelledRef.current) return;
|
||||
}
|
||||
|
||||
setPhase("connecting");
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
navigate("/", { replace: true });
|
||||
} catch (e) {
|
||||
if (cancelledRef.current) return;
|
||||
setErrorMsg(String(e));
|
||||
setPhase("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, [navigate, attempt]);
|
||||
|
||||
// restart aborts any in-flight wait by toggling the cancellation flag,
|
||||
// tells the daemon to drop whatever it's holding (a stale WaitSSOLogin
|
||||
// can wedge the daemon for a previous UserCode), and then bumps attempt
|
||||
// so the effect re-runs with a clean slate.
|
||||
const restart = useCallback(async () => {
|
||||
cancelledRef.current = true;
|
||||
try {
|
||||
await Connection.Down();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setAttempt((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
// Cancel must also tell the daemon to abandon the in-flight WaitSSOLogin.
|
||||
// Without Down(), the daemon stays parked on the OAuth flow's UserCode
|
||||
// forever; subsequent Login calls re-use the cached flow but the user has
|
||||
// no way out. Down() triggers the daemon's actCancel(), which unblocks
|
||||
// WaitSSOLogin with a context-canceled error so our promise settles.
|
||||
const cancel = useCallback(async () => {
|
||||
cancelledRef.current = true;
|
||||
try {
|
||||
await Connection.Down();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
navigate("/", { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
if (phase === "error") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<AlertTriangle className="h-8 w-8 text-red-500" strokeWidth={1.5} />
|
||||
<h1 className="text-xl font-semibold">Login failed</h1>
|
||||
<p className="max-w-sm break-words text-sm text-nb-gray-500">{errorMsg}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={restart}>
|
||||
<RotateCcw className="h-4 w-4" strokeWidth={1.5} /> Try again
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={cancel}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "browser") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<h1 className="text-xl font-semibold">Continue in your browser</h1>
|
||||
<p className="max-w-sm text-sm text-nb-gray-500">
|
||||
A browser tab should have opened. Sign in there — this window will
|
||||
continue automatically once you're done.
|
||||
</p>
|
||||
{verificationUri && (
|
||||
<Button onClick={() => Connection.OpenURL(verificationUri).catch(console.error)}>
|
||||
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
|
||||
Reopen URL
|
||||
</Button>
|
||||
)}
|
||||
<p className="max-w-sm break-all font-mono text-xs text-nb-gray-500">
|
||||
{verificationUri}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm text-nb-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
|
||||
Waiting for sign-in…
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={restart}>
|
||||
<RotateCcw className="h-4 w-4" strokeWidth={1.5} /> Restart
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={cancel}>
|
||||
<X className="h-4 w-4" strokeWidth={1.5} /> Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
phase === "connecting" ? "Bringing the connection up…" : "Starting login…";
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
|
||||
<p className="text-sm text-nb-gray-500">{message}</p>
|
||||
<Button variant="ghost" onClick={cancel}>
|
||||
<X className="h-4 w-4" strokeWidth={1.5} /> Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
client/ui/frontend/src/pages/LoginUrl.tsx
Normal file
35
client/ui/frontend/src/pages/LoginUrl.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import { Button } from "../components/Button";
|
||||
|
||||
export default function LoginUrl() {
|
||||
const [url, setUrl] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split("?")[1] ?? "");
|
||||
setUrl(params.get("url") ?? "");
|
||||
}, []);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-nb-gray-500">
|
||||
No login URL provided.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<h1 className="text-xl font-semibold">Continue in your browser</h1>
|
||||
<p className="max-w-sm text-sm text-nb-gray-500">
|
||||
Open the following URL to finish signing in.
|
||||
</p>
|
||||
<Button onClick={() => Connection.OpenURL(url).catch(console.error)}>
|
||||
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
|
||||
Open URL
|
||||
</Button>
|
||||
<p className="max-w-sm break-all font-mono text-xs text-nb-gray-500">{url}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
client/ui/frontend/src/pages/Update.tsx
Normal file
135
client/ui/frontend/src/pages/Update.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
// How long the daemon is allowed to be unreachable before we treat it as
|
||||
// "daemon went down for the upgrade, treat as success and quit". Mirrors
|
||||
// the legacy Fyne UI's branch in client/ui/update.go where a connection
|
||||
// failure during polling is taken as the success signal.
|
||||
const DAEMON_DOWN_GRACE_MS = 5000;
|
||||
|
||||
type Phase =
|
||||
| { kind: "running"; dots: number }
|
||||
| { kind: "timeout" }
|
||||
| { kind: "canceled" }
|
||||
| { kind: "failed"; message: string };
|
||||
|
||||
export default function Update() {
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "running", dots: 1 });
|
||||
const phaseRef = useRef(phase);
|
||||
phaseRef.current = phase;
|
||||
|
||||
const version = new URLSearchParams(
|
||||
window.location.hash.split("?")[1] ?? "",
|
||||
).get("version");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const start = Date.now();
|
||||
let firstUnreachableAt: number | null = null;
|
||||
|
||||
UpdateSvc.Trigger().catch(() => {
|
||||
// The daemon may already be down (installer launched, daemon shutting
|
||||
// down). Don't treat as failure here; the poll loop's daemon-down
|
||||
// detection handles it.
|
||||
});
|
||||
|
||||
const dotTimer = setInterval(() => {
|
||||
if (cancelled) return;
|
||||
setPhase((p) =>
|
||||
p.kind === "running" ? { kind: "running", dots: (p.dots % 3) + 1 } : p,
|
||||
);
|
||||
}, 1000);
|
||||
|
||||
const pollTimer = setInterval(async () => {
|
||||
if (cancelled) return;
|
||||
if (phaseRef.current.kind !== "running") return;
|
||||
|
||||
if (Date.now() - start > TIMEOUT_MS) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(dotTimer);
|
||||
setPhase({ kind: "timeout" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await UpdateSvc.GetInstallerResult();
|
||||
firstUnreachableAt = null;
|
||||
if (r.success) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(dotTimer);
|
||||
UpdateSvc.Quit();
|
||||
return;
|
||||
}
|
||||
if (r.errorMsg) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(dotTimer);
|
||||
setPhase(mapInstallError(r.errorMsg));
|
||||
}
|
||||
} catch {
|
||||
// RPC failed. The daemon often goes away mid-upgrade — treat a
|
||||
// sustained outage as success and quit, matching the legacy UI.
|
||||
const now = Date.now();
|
||||
if (firstUnreachableAt === null) {
|
||||
firstUnreachableAt = now;
|
||||
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(dotTimer);
|
||||
UpdateSvc.Quit();
|
||||
}
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(dotTimer);
|
||||
clearInterval(pollTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const versionLine = version
|
||||
? `Updating client to: ${version}.`
|
||||
: "Updating client.";
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="whitespace-pre-line text-sm text-nb-gray-700 dark:text-nb-gray-200">
|
||||
{`Your client version is older than the auto-update version set in Management.\n${versionLine}`}
|
||||
</p>
|
||||
<p className="text-base font-medium">{statusText(phase)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(p: Phase): string {
|
||||
switch (p.kind) {
|
||||
case "running":
|
||||
return "Updating" + ".".repeat(p.dots);
|
||||
case "timeout":
|
||||
return "Update timed out. Please try again.";
|
||||
case "canceled":
|
||||
return "Update canceled.";
|
||||
case "failed":
|
||||
return "Update failed: " + p.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors mapInstallError in client/ui/update.go. The daemon's installer
|
||||
// surfaces error strings rather than typed errors, so the UI sniffs the
|
||||
// message to decide whether to show the timeout/canceled wording.
|
||||
function mapInstallError(msg: string): Phase {
|
||||
const m = msg.trim().toLowerCase();
|
||||
if (m === "") {
|
||||
return { kind: "failed", message: "unknown update error" };
|
||||
}
|
||||
if (m.includes("deadline exceeded") || m.includes("timeout")) {
|
||||
return { kind: "timeout" };
|
||||
}
|
||||
if (m.includes("canceled") || m.includes("cancelled")) {
|
||||
return { kind: "canceled" };
|
||||
}
|
||||
return { kind: "failed", message: msg };
|
||||
}
|
||||
110
client/ui/frontend/src/screens/Debug.tsx
Normal file
110
client/ui/frontend/src/screens/Debug.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Debug.tsx
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
========
|
||||
import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Debug.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
import { Switch } from "../components/Switch";
|
||||
import { Card } from "../components/Card";
|
||||
|
||||
export default function Debug() {
|
||||
const [anonymize, setAnonymize] = useState(true);
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(false);
|
||||
const [uploadUrl, setUploadUrl] = useState("");
|
||||
const [logFiles, setLogFiles] = useState(0);
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<DebugBundleResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await DebugSvc.Bundle({
|
||||
anonymize,
|
||||
systemInfo,
|
||||
uploadUrl: upload ? uploadUrl : "",
|
||||
logFileCount: logFiles,
|
||||
});
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<h1 className="text-xl font-semibold">Debug bundle</h1>
|
||||
|
||||
<Card className="space-y-4">
|
||||
<Switch
|
||||
checked={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label="Anonymize"
|
||||
description="Replace IPs and identifiers in the bundle."
|
||||
/>
|
||||
<Switch
|
||||
checked={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
label="Include system information"
|
||||
/>
|
||||
<Switch
|
||||
checked={upload}
|
||||
onChange={setUpload}
|
||||
label="Upload on create"
|
||||
/>
|
||||
{upload && (
|
||||
<Input
|
||||
label="Upload URL"
|
||||
value={uploadUrl}
|
||||
onChange={(e) => setUploadUrl(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label="Log file count"
|
||||
type="number"
|
||||
value={logFiles}
|
||||
onChange={(e) => setLogFiles(Number(e.target.value))}
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<Button onClick={run} disabled={running}>
|
||||
{running ? "Generating…" : "Create bundle"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
{result.path && (
|
||||
<p className="text-sm">
|
||||
<span className="text-nb-gray-500">Path:</span>{" "}
|
||||
<span className="font-mono">{result.path}</span>
|
||||
</p>
|
||||
)}
|
||||
{result.uploadedKey && (
|
||||
<p className="text-sm">
|
||||
<span className="text-nb-gray-500">Uploaded key:</span>{" "}
|
||||
<span className="font-mono">{result.uploadedKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{result.uploadFailureReason && (
|
||||
<p className="text-sm text-red-500">
|
||||
Upload failed: {result.uploadFailureReason}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
client/ui/frontend/src/screens/Networks.tsx
Normal file
167
client/ui/frontend/src/screens/Networks.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Networks.tsx
|
||||
import { Networks as NetworksSvc } from "@bindings/services";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
========
|
||||
import { Networks as NetworksSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { Network } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Networks.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Tabs } from "../components/Tabs";
|
||||
|
||||
export default function Networks() {
|
||||
const [routes, setRoutes] = useState<Network[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await NetworksSvc.List();
|
||||
setRoutes(list);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const toggle = async (id: string, selected: boolean) => {
|
||||
try {
|
||||
if (selected) {
|
||||
await NetworksSvc.Deselect({ networkIds: [id], append: false, all: false });
|
||||
} else {
|
||||
await NetworksSvc.Select({ networkIds: [id], append: true, all: false });
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const setAll = async (ids: string[], on: boolean) => {
|
||||
try {
|
||||
if (on) {
|
||||
await NetworksSvc.Select({ networkIds: ids, append: false, all: true });
|
||||
} else {
|
||||
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: true });
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const overlapping = useMemo(() => filterOverlapping(routes), [routes]);
|
||||
const exitNodes = useMemo(
|
||||
() => routes.filter((r) => r.range === "0.0.0.0/0" || r.range === "::/0"),
|
||||
[routes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Networks</h1>
|
||||
<Button variant="secondary" size="sm" onClick={refresh} disabled={loading}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} strokeWidth={1.5} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mb-2 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
value: "all",
|
||||
label: `All (${routes.length})`,
|
||||
content: <NetworkList routes={routes} onToggle={toggle} onSetAll={setAll} />,
|
||||
},
|
||||
{
|
||||
value: "overlap",
|
||||
label: `Overlapping (${overlapping.length})`,
|
||||
content: <NetworkList routes={overlapping} onToggle={toggle} onSetAll={setAll} />,
|
||||
},
|
||||
{
|
||||
value: "exit",
|
||||
label: `Exit-node (${exitNodes.length})`,
|
||||
content: <NetworkList routes={exitNodes} onToggle={toggle} onSetAll={setAll} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkList({
|
||||
routes,
|
||||
onToggle,
|
||||
onSetAll,
|
||||
}: {
|
||||
routes: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
onSetAll: (ids: string[], on: boolean) => void;
|
||||
}) {
|
||||
if (routes.length === 0) {
|
||||
return <p className="p-4 text-sm text-nb-gray-500">No networks.</p>;
|
||||
}
|
||||
const ids = routes.map((r) => r.id);
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 gap-2 border-b border-nb-gray-200 px-4 py-2 dark:border-nb-gray-800">
|
||||
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, true)}>
|
||||
Select all
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, false)}>
|
||||
Deselect all
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex-1 overflow-auto divide-y divide-nb-gray-200 dark:divide-nb-gray-800">
|
||||
{routes.map((r) => (
|
||||
<li key={r.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={r.selected}
|
||||
onChange={() => onToggle(r.id, r.selected)}
|
||||
className="mt-1 h-4 w-4 accent-netbird"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{r.id}</p>
|
||||
<p className="truncate font-mono text-xs text-nb-gray-500">{r.range}</p>
|
||||
{r.domains.length > 0 && (
|
||||
<p className="mt-0.5 truncate text-xs text-nb-gray-500">
|
||||
{r.domains.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function filterOverlapping(routes: Network[]): Network[] {
|
||||
const byRange = new Map<string, Network[]>();
|
||||
for (const r of routes) {
|
||||
if (r.domains.length > 0) continue;
|
||||
const arr = byRange.get(r.range) ?? [];
|
||||
arr.push(r);
|
||||
byRange.set(r.range, arr);
|
||||
}
|
||||
const out: Network[] = [];
|
||||
for (const arr of byRange.values()) {
|
||||
if (arr.length > 1) out.push(...arr);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
215
client/ui/frontend/src/screens/Peers.tsx
Normal file
215
client/ui/frontend/src/screens/Peers.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react";
|
||||
import { useStatus } from "../hooks/useStatus";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Peers.tsx
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
========
|
||||
import type { PeerStatus } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Peers.tsx
|
||||
import { Card } from "../components/Card";
|
||||
import { Input } from "../components/Input";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export default function Peers() {
|
||||
const { status } = useStatus();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const peers = useMemo(() => {
|
||||
const all = status?.peers ?? [];
|
||||
if (!filter.trim()) return all;
|
||||
const q = filter.trim().toLowerCase();
|
||||
return all.filter(
|
||||
(p) =>
|
||||
p.fqdn.toLowerCase().includes(q) ||
|
||||
p.ip.toLowerCase().includes(q) ||
|
||||
p.networks.some((n) => n.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [status?.peers, filter]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">
|
||||
Peers
|
||||
<span className="ml-2 text-sm font-normal text-nb-gray-500">
|
||||
{status?.peers?.length ?? 0}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="w-64">
|
||||
<Input
|
||||
placeholder="Filter by FQDN / IP / network"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{peers.length === 0 ? (
|
||||
<Card className="text-sm text-nb-gray-500">
|
||||
{status?.peers?.length === 0
|
||||
? "No peers visible from this client."
|
||||
: "No peers match the filter."}
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="flex-1 divide-y divide-nb-gray-200 overflow-auto rounded-lg border border-nb-gray-200 dark:divide-nb-gray-800 dark:border-nb-gray-800">
|
||||
{peers.map((p) => (
|
||||
<PeerRow
|
||||
key={p.pubKey}
|
||||
peer={p}
|
||||
expanded={expanded === p.pubKey}
|
||||
onToggle={() => setExpanded(expanded === p.pubKey ? null : p.pubKey)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PeerRow({
|
||||
peer,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
peer: PeerStatus;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-nb-gray-50 dark:hover:bg-nb-gray-940"
|
||||
>
|
||||
<ChevronIcon expanded={expanded} />
|
||||
<StateBadge state={peer.connStatus} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{peer.fqdn || "—"}</p>
|
||||
<p className="truncate font-mono text-xs text-nb-gray-500">{peer.ip}</p>
|
||||
</div>
|
||||
<RouteIcon relayed={peer.relayed} connected={peer.connStatus === "Connected"} />
|
||||
{peer.rosenpassEnabled && (
|
||||
<ShieldCheck className="h-4 w-4 text-green-500" strokeWidth={1.5} />
|
||||
)}
|
||||
<span className="w-16 text-right text-xs text-nb-gray-500">
|
||||
{peer.connStatus === "Connected" && peer.latencyMs > 0
|
||||
? `${peer.latencyMs} ms`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && <PeerDetails peer={peer} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PeerDetails({ peer }: { peer: PeerStatus }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 bg-nb-gray-50 px-12 py-3 text-xs dark:bg-nb-gray-940">
|
||||
<Detail label="Public key" value={peer.pubKey} mono />
|
||||
<Detail label="Last handshake" value={fmtRelative(peer.lastHandshakeUnix)} />
|
||||
<Detail label="Status since" value={fmtRelative(peer.connStatusUpdateUnix)} />
|
||||
<Detail
|
||||
label="Bytes rx / tx"
|
||||
value={`${fmtBytes(peer.bytesRx)} / ${fmtBytes(peer.bytesTx)}`}
|
||||
/>
|
||||
<Detail
|
||||
label="Local candidate"
|
||||
value={
|
||||
peer.localIceCandidateType
|
||||
? `${peer.localIceCandidateType} (${peer.localIceCandidateEndpoint || "—"})`
|
||||
: "—"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<Detail
|
||||
label="Remote candidate"
|
||||
value={
|
||||
peer.remoteIceCandidateType
|
||||
? `${peer.remoteIceCandidateType} (${peer.remoteIceCandidateEndpoint || "—"})`
|
||||
: "—"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
{peer.relayed && (
|
||||
<Detail label="Relay" value={peer.relayAddress || "—"} mono />
|
||||
)}
|
||||
{peer.networks.length > 0 && (
|
||||
<Detail label="Networks" value={peer.networks.join(", ")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<span className="shrink-0 text-nb-gray-500">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-nb-gray-700 dark:text-nb-gray-200",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
||||
const Icon = expanded ? ChevronDown : ChevronRight;
|
||||
return <Icon className="h-4 w-4 shrink-0 text-nb-gray-400" strokeWidth={1.5} />;
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: string }) {
|
||||
const cls = "h-2 w-2 rounded-full shrink-0";
|
||||
switch (state) {
|
||||
case "Connected":
|
||||
return <span className={cn(cls, "bg-green-500")} title="Connected" />;
|
||||
case "Connecting":
|
||||
return <span className={cn(cls, "bg-netbird animate-pulse")} title="Connecting" />;
|
||||
case "Idle":
|
||||
return <span className={cn(cls, "bg-yellow-500")} title="Idle" />;
|
||||
default:
|
||||
return <span className={cn(cls, "bg-nb-gray-400")} title={state || "Disconnected"} />;
|
||||
}
|
||||
}
|
||||
|
||||
function RouteIcon({ relayed, connected }: { relayed: boolean; connected: boolean }) {
|
||||
if (!connected) {
|
||||
return <span className="w-4 shrink-0" />;
|
||||
}
|
||||
if (relayed) {
|
||||
return (
|
||||
<Network
|
||||
className="h-4 w-4 text-yellow-600"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<title>Relayed</title>
|
||||
</Network>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Zap className="h-4 w-4 text-green-600" strokeWidth={1.5}>
|
||||
<title>P2P</title>
|
||||
</Zap>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function fmtRelative(unixSec: number): string {
|
||||
if (!unixSec) return "—";
|
||||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
|
||||
if (ageSec < 60) return `${ageSec}s ago`;
|
||||
if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m ago`;
|
||||
if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h ago`;
|
||||
return `${Math.floor(ageSec / 86400)}d ago`;
|
||||
}
|
||||
179
client/ui/frontend/src/screens/Profiles.tsx
Normal file
179
client/ui/frontend/src/screens/Profiles.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Profiles as ProfilesSvc,
|
||||
Connection,
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Profiles.tsx
|
||||
} from "@bindings/services";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
========
|
||||
} from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Profiles.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
import { Card } from "../components/Card";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
|
||||
export default function Profiles() {
|
||||
const { username, loaded, refresh: refreshProfile, switchProfile } = useProfile();
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!username) return;
|
||||
try {
|
||||
const list = await ProfilesSvc.List(username);
|
||||
setProfiles(list);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) refresh();
|
||||
}, [loaded, refresh]);
|
||||
|
||||
const select = async (name: string) => {
|
||||
try {
|
||||
await switchProfile(name);
|
||||
await Connection.Up({ profileName: name, username });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const deregister = async (name: string) => {
|
||||
try {
|
||||
await Connection.Logout({ profileName: name, username });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (name: string) => {
|
||||
if (name === "default") return;
|
||||
try {
|
||||
await ProfilesSvc.Remove({ profileName: name, username });
|
||||
await refreshProfile();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Profiles</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={refresh}>
|
||||
<RefreshCw className="h-3.5 w-3.5" strokeWidth={1.5} /> Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAdding(true)}>
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={1.5} /> Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
{profiles.map((p) => (
|
||||
<Card key={p.name} className="flex items-center gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="active-profile"
|
||||
checked={p.isActive}
|
||||
onChange={() => select(p.name)}
|
||||
className="h-4 w-4 accent-netbird"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{p.name}</p>
|
||||
{p.isActive && <p className="text-xs text-nb-gray-500">Active</p>}
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => deregister(p.name)}>
|
||||
Deregister
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={p.name === "default"}
|
||||
onClick={() => remove(p.name)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<p className="text-sm text-nb-gray-500">No profiles.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<AddDialog
|
||||
username={username}
|
||||
onClose={() => setAdding(false)}
|
||||
onAdded={async () => {
|
||||
setAdding(false);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDialog({
|
||||
username,
|
||||
onClose,
|
||||
onAdded,
|
||||
}: {
|
||||
username: string;
|
||||
onClose: () => void;
|
||||
onAdded: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
try {
|
||||
await ProfilesSvc.Add({ profileName: name.trim(), username });
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="w-80 rounded-lg border border-nb-gray-200 bg-white p-4 shadow-lg dark:border-nb-gray-800 dark:bg-nb-gray-925"
|
||||
>
|
||||
<h2 className="mb-3 text-base font-semibold">New profile</h2>
|
||||
<Input
|
||||
autoFocus
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
{err && <p className="mt-2 text-xs text-red-500">{err}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
client/ui/frontend/src/screens/QuickActions.tsx
Normal file
44
client/ui/frontend/src/screens/QuickActions.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CheckCircle2, Circle, Loader2, Power } from "lucide-react";
|
||||
import { useStatus } from "../hooks/useStatus";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/QuickActions.tsx
|
||||
import { Connection } from "@bindings/services";
|
||||
========
|
||||
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/QuickActions.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export default function QuickActions() {
|
||||
const { status } = useStatus();
|
||||
const state = status?.status ?? "Disconnected";
|
||||
const connected = state === "Connected";
|
||||
const connecting = state === "Connecting";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
|
||||
<Icon state={state} />
|
||||
<p className="text-lg font-medium">{state}</p>
|
||||
{connected ? (
|
||||
<Button variant="secondary" onClick={() => Connection.Down()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => Connection.Up({ profileName: "", username: "" })} disabled={connecting}>
|
||||
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ state }: { state: string }) {
|
||||
const cls = "h-12 w-12";
|
||||
switch (state) {
|
||||
case "Connected":
|
||||
return <CheckCircle2 className={cn(cls, "text-green-500")} strokeWidth={1.5} />;
|
||||
case "Connecting":
|
||||
return <Loader2 className={cn(cls, "animate-spin text-netbird")} strokeWidth={1.5} />;
|
||||
default:
|
||||
return <Circle className={cn(cls, "text-nb-gray-400")} strokeWidth={1.5} />;
|
||||
}
|
||||
}
|
||||
251
client/ui/frontend/src/screens/Settings.tsx
Normal file
251
client/ui/frontend/src/screens/Settings.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Settings as SettingsSvc,
|
||||
Profiles as ProfilesSvc,
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Settings.tsx
|
||||
} from "@bindings/services";
|
||||
import type { Config } from "@bindings/services/models.js";
|
||||
========
|
||||
} from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { Config } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Settings.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
import { Switch } from "../components/Switch";
|
||||
import { Tabs } from "../components/Tabs";
|
||||
|
||||
interface Ctx {
|
||||
cfg: Config;
|
||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [profile, setProfile] = useState("");
|
||||
const [cfg, setCfg] = useState<Config | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const u = await ProfilesSvc.Username();
|
||||
const active = await ProfilesSvc.GetActive();
|
||||
const profileName = active.profileName || "default";
|
||||
setUsername(u);
|
||||
setProfile(profileName);
|
||||
const c = await SettingsSvc.GetConfig({ profileName, username: u });
|
||||
setCfg(c);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const setField: Ctx["setField"] = (k, v) => {
|
||||
setCfg((c) => (c ? { ...c, [k]: v } : c));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!cfg) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
profileName: profile,
|
||||
username,
|
||||
managementUrl: cfg.managementUrl,
|
||||
adminUrl: cfg.adminUrl,
|
||||
interfaceName: cfg.interfaceName,
|
||||
wireguardPort: cfg.wireguardPort,
|
||||
mtu: cfg.mtu,
|
||||
preSharedKey: cfg.preSharedKey,
|
||||
disableAutoConnect: cfg.disableAutoConnect,
|
||||
serverSshAllowed: cfg.serverSshAllowed,
|
||||
rosenpassEnabled: cfg.rosenpassEnabled,
|
||||
rosenpassPermissive: cfg.rosenpassPermissive,
|
||||
disableNotifications: cfg.disableNotifications,
|
||||
lazyConnectionEnabled: cfg.lazyConnectionEnabled,
|
||||
blockInbound: cfg.blockInbound,
|
||||
networkMonitor: cfg.networkMonitor,
|
||||
disableClientRoutes: cfg.disableClientRoutes,
|
||||
disableServerRoutes: cfg.disableServerRoutes,
|
||||
disableDns: cfg.disableDns,
|
||||
disableIpv6: cfg.disableIpv6,
|
||||
blockLanAccess: cfg.blockLanAccess,
|
||||
enableSshRoot: cfg.enableSshRoot,
|
||||
enableSshSftp: cfg.enableSshSftp,
|
||||
enableSshLocalPortForwarding: cfg.enableSshLocalPortForwarding,
|
||||
enableSshRemotePortForwarding: cfg.enableSshRemotePortForwarding,
|
||||
disableSshAuth: cfg.disableSshAuth,
|
||||
sshJwtCacheTtl: cfg.sshJwtCacheTtl,
|
||||
});
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!cfg) {
|
||||
return <div className="p-6 text-sm text-nb-gray-500">Loading…</div>;
|
||||
}
|
||||
|
||||
const ctx: Ctx = { cfg, setField };
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-nb-gray-200 px-6 py-3 dark:border-nb-gray-800">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="px-6 py-2 text-sm text-red-500">{error}</p>}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ value: "conn", label: "Connection", content: <ConnectionTab {...ctx} /> },
|
||||
{ value: "net", label: "Network", content: <NetworkTab {...ctx} /> },
|
||||
{ value: "ssh", label: "SSH", content: <SSHTab {...ctx} /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionTab({ cfg, setField }: Ctx) {
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-4 p-6">
|
||||
<Input
|
||||
label="Management URL"
|
||||
value={cfg.managementUrl}
|
||||
onChange={(e) => setField("managementUrl", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Pre-shared key"
|
||||
type="password"
|
||||
value={cfg.preSharedKey}
|
||||
onChange={(e) => setField("preSharedKey", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Interface name"
|
||||
value={cfg.interfaceName}
|
||||
onChange={(e) => setField("interfaceName", e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="WireGuard port"
|
||||
type="number"
|
||||
value={cfg.wireguardPort}
|
||||
onChange={(e) => setField("wireguardPort", Number(e.target.value))}
|
||||
/>
|
||||
<Input
|
||||
label="MTU"
|
||||
type="number"
|
||||
value={cfg.mtu}
|
||||
onChange={(e) => setField("mtu", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cfg.rosenpassEnabled}
|
||||
onChange={(v) => setField("rosenpassEnabled", v)}
|
||||
label="Rosenpass (post-quantum)"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.rosenpassPermissive}
|
||||
onChange={(v) => setField("rosenpassPermissive", v)}
|
||||
label="Rosenpass permissive mode"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkTab({ cfg, setField }: Ctx) {
|
||||
return (
|
||||
<div className="grid max-w-xl gap-4 p-6">
|
||||
<Switch
|
||||
checked={cfg.networkMonitor}
|
||||
onChange={(v) => setField("networkMonitor", v)}
|
||||
label="Network monitor"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableDns}
|
||||
onChange={(v) => setField("disableDns", v)}
|
||||
label="Disable DNS"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableClientRoutes}
|
||||
onChange={(v) => setField("disableClientRoutes", v)}
|
||||
label="Disable client routes"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableServerRoutes}
|
||||
onChange={(v) => setField("disableServerRoutes", v)}
|
||||
label="Disable server routes"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableIpv6}
|
||||
onChange={(v) => setField("disableIpv6", v)}
|
||||
label="Disable IPv6 overlay addressing"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.blockLanAccess}
|
||||
onChange={(v) => setField("blockLanAccess", v)}
|
||||
label="Block LAN access"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.blockInbound}
|
||||
onChange={(v) => setField("blockInbound", v)}
|
||||
label="Block inbound connections"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SSHTab({ cfg, setField }: Ctx) {
|
||||
return (
|
||||
<div className="grid max-w-xl gap-4 p-6">
|
||||
<Switch
|
||||
checked={cfg.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
label="Server SSH allowed"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
label="SSH root login"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.enableSshSftp}
|
||||
onChange={(v) => setField("enableSshSftp", v)}
|
||||
label="SFTP"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.enableSshLocalPortForwarding}
|
||||
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
|
||||
label="Local port forwarding"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.enableSshRemotePortForwarding}
|
||||
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
|
||||
label="Remote port forwarding"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", v)}
|
||||
label="Disable SSH auth"
|
||||
/>
|
||||
<Input
|
||||
label="JWT cache TTL (seconds)"
|
||||
type="number"
|
||||
value={cfg.sshJwtCacheTtl}
|
||||
onChange={(e) => setField("sshJwtCacheTtl", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
client/ui/frontend/src/screens/Status.tsx
Normal file
202
client/ui/frontend/src/screens/Status.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useStatus } from "../hooks/useStatus";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Status.tsx
|
||||
import { Connection } from "@bindings/services";
|
||||
import type { SystemEvent } from "@bindings/services/models.js";
|
||||
========
|
||||
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Status.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { cn } from "../lib/cn";
|
||||
import { NetBirdConnectToggle, ConnectionState } from "../components/NetBirdConnectToggle";
|
||||
|
||||
export default function Status() {
|
||||
const { status, error } = useStatus();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const connState = status?.status ?? "Disconnected";
|
||||
const connected = connState === "Connected";
|
||||
const connecting = connState === "Connecting";
|
||||
// The daemon reports "NeedsLogin" on a fresh install or after a session
|
||||
// expires; "SessionExpired" once a previously good session lapses. In both
|
||||
// cases Connect would fail without a fresh SSO login.
|
||||
const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired";
|
||||
// Always offer Login while we aren't Connected — including Connecting,
|
||||
// because a stuck Login on the daemon leaves us in Connecting forever and
|
||||
// the user has no other way out. Disconnect is the manual unstick path.
|
||||
const showLogin = !connected;
|
||||
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Status.tsx
|
||||
const toggleState: ConnectionState =
|
||||
connected ? ConnectionState.Connected
|
||||
: connecting ? ConnectionState.Connecting
|
||||
: ConnectionState.Disconnected;
|
||||
|
||||
========
|
||||
const login = () => navigate("/login");
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Status.tsx
|
||||
const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error);
|
||||
const disconnect = () => Connection.Down().catch(console.error);
|
||||
const toggleConnection = () => (connected ? disconnect() : connect());
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StateIcon state={connState} />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold leading-none">{connState}</h1>
|
||||
<p className="mt-1 text-sm text-nb-gray-500">
|
||||
{status?.local.fqdn || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{needsLogin ? (
|
||||
<Button onClick={login}>
|
||||
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={connect} disabled={connected || connecting}>
|
||||
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
|
||||
</Button>
|
||||
)}
|
||||
{showLogin && !needsLogin && (
|
||||
<Button onClick={login} variant="secondary">
|
||||
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={disconnect} variant="secondary" disabled={!connected}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4" strokeWidth={1.5} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoCard label="Local IP" value={status?.local.ip || "—"} />
|
||||
<InfoCard label="Peers" value={String(status?.peers?.length ?? 0)} />
|
||||
<LinkCard label="Management" link={status?.management} />
|
||||
<LinkCard label="Signal" link={status?.signal} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-semibold text-nb-gray-700 dark:text-nb-gray-200">
|
||||
Recent events
|
||||
</h2>
|
||||
{(() => {
|
||||
const events = dedupEvents(status?.events ?? []).slice(0, 8);
|
||||
if (events.length === 0) {
|
||||
return <p className="text-sm text-nb-gray-500">No recent events.</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{events.map((e, i) => (
|
||||
<li key={`${e.id}-${i}`} className="flex gap-2">
|
||||
<span className="shrink-0 font-mono text-xs text-nb-gray-500">
|
||||
{e.severity}
|
||||
</span>
|
||||
<span className="text-nb-gray-700 dark:text-nb-gray-200">
|
||||
{e.userMessage || e.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center bg-nb-gray p-10">
|
||||
<NetBirdConnectToggle state={toggleState} onClick={toggleConnection} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateIcon({ state }: { state: string }) {
|
||||
const cls = "h-7 w-7";
|
||||
switch (state) {
|
||||
case "Connected":
|
||||
return <CheckCircle2 className={cn(cls, "text-green-500")} strokeWidth={1.5} />;
|
||||
case "Connecting":
|
||||
return <Loader2 className={cn(cls, "animate-spin text-netbird")} strokeWidth={1.5} />;
|
||||
case "Error":
|
||||
return <AlertTriangle className={cn(cls, "text-red-500")} strokeWidth={1.5} />;
|
||||
default:
|
||||
return <Circle className={cn(cls, "text-nb-gray-400")} strokeWidth={1.5} />;
|
||||
}
|
||||
}
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
|
||||
<p className="mt-1 truncate font-mono text-sm">{value}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// dedupEvents collapses repeated daemon events that carry the same logical
|
||||
// content. The daemon emits one "new_version_available" event per check tick,
|
||||
// so its 10-event ring buffer fills with duplicates after a quiet hour. Same
|
||||
// goes for periodic "DNS unreachable" or "auth retry" events. We key by
|
||||
// message + a small set of identity-bearing metadata fields and keep the
|
||||
// newest occurrence (the events array is already in publish order).
|
||||
function dedupEvents(events: SystemEvent[]): SystemEvent[] {
|
||||
const seen = new Set<string>();
|
||||
const out: SystemEvent[] = [];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
const md = e.metadata ?? {};
|
||||
const key = [
|
||||
e.severity,
|
||||
e.category,
|
||||
e.userMessage || e.message,
|
||||
md["new_version_available"] ?? "",
|
||||
md["enforced"] ?? "",
|
||||
].join("|");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[dedup]", { key, event: e });
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.unshift(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function LinkCard({
|
||||
label,
|
||||
link,
|
||||
}: {
|
||||
label: string;
|
||||
link?: { url: string; connected: boolean; error?: string };
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
link?.connected ? "bg-green-500" : "bg-nb-gray-400",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-nb-gray-600 dark:text-nb-gray-300">
|
||||
{link?.url || "—"}
|
||||
</p>
|
||||
{link?.error && (
|
||||
<p className="mt-1 truncate text-xs text-red-500">{link.error}</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user