[client/ui] Replace fyne UI with Wails (rename ui-wails to ui)

Removes the legacy fyne-based client/ui implementation and renames the
Wails replacement (client/ui-wails) to take its place at client/ui. Go
imports, frontend bindings, CI workflows, goreleaser configs and the
windows .syso icon path are updated to follow the rename.
This commit is contained in:
Zoltán Papp
2026-05-11 11:20:22 +02:00
parent 08f52f4517
commit 9aef31ff53
189 changed files with 82 additions and 5840 deletions

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -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(1062334452);
}
export function Login(p: $models.LoginParams): $CancellablePromise<$models.LoginResult> {
return $Call.ByID(782816741, p).then(($result: any) => {
return $$createType0($result);
});
}
export function Logout(p: $models.LogoutParams): $CancellablePromise<void> {
return $Call.ByID(4028053230, 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(4267001345, url);
}
export function Up(p: $models.UpParams): $CancellablePromise<void> {
return $Call.ByID(1178388469, p);
}
export function WaitSSOLogin(p: $models.WaitSSOParams): $CancellablePromise<string> {
return $Call.ByID(3487329509, p);
}
// Private type creation functions
const $$createType0 = $models.LoginResult.createFrom;

View File

@@ -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(1875836985, p).then(($result: any) => {
return $$createType0($result);
});
}
export function GetLogLevel(): $CancellablePromise<$models.LogLevel> {
return $Call.ByID(2713455331).then(($result: any) => {
return $$createType1($result);
});
}
export function SetLogLevel(lvl: $models.LogLevel): $CancellablePromise<void> {
return $Call.ByID(2627038775, lvl);
}
// Private type creation functions
const $$createType0 = $models.DebugBundleResult.createFrom;
const $$createType1 = $models.LogLevel.createFrom;

View File

@@ -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(3893357601).then(($result: any) => {
return $$createType1($result);
});
}
// Private type creation functions
const $$createType0 = $models.ForwardingRule.createFrom;
const $$createType1 = $Create.Array($$createType0);

View File

@@ -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";

View File

@@ -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(2335193802, p);
}
export function List(): $CancellablePromise<$models.Network[]> {
return $Call.ByID(719769457).then(($result: any) => {
return $$createType1($result);
});
}
export function Select(p: $models.SelectNetworksParams): $CancellablePromise<void> {
return $Call.ByID(3714393053, p);
}
// Private type creation functions
const $$createType0 = $models.Network.createFrom;
const $$createType1 = $Create.Array($$createType0);

View File

@@ -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(196038193).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(741320382);
}
// Private type creation functions
const $$createType0 = $models.Status.createFrom;

View File

@@ -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(701512397, p);
}
export function GetActive(): $CancellablePromise<$models.ActiveProfile> {
return $Call.ByID(2605259596).then(($result: any) => {
return $$createType0($result);
});
}
export function List(username: string): $CancellablePromise<$models.Profile[]> {
return $Call.ByID(1745269178, username).then(($result: any) => {
return $$createType2($result);
});
}
export function Remove(p: $models.ProfileRef): $CancellablePromise<void> {
return $Call.ByID(2506403914, p);
}
export function Switch(p: $models.ProfileRef): $CancellablePromise<void> {
return $Call.ByID(3405248534, 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(1939223418);
}
// Private type creation functions
const $$createType0 = $models.ActiveProfile.createFrom;
const $$createType1 = $models.Profile.createFrom;
const $$createType2 = $Create.Array($$createType1);

View File

@@ -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(2849966711, p).then(($result: any) => {
return $$createType0($result);
});
}
export function GetFeatures(): $CancellablePromise<$models.Features> {
return $Call.ByID(376812026).then(($result: any) => {
return $$createType1($result);
});
}
export function SetConfig(p: $models.SetConfigParams): $CancellablePromise<void> {
return $Call.ByID(565510651, p);
}
// Private type creation functions
const $$createType0 = $models.Config.createFrom;
const $$createType1 = $models.Features.createFrom;

View File

@@ -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(2190725314).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(27817640);
}
export function Trigger(): $CancellablePromise<$models.UpdateResult> {
return $Call.ByID(2415339649).then(($result: any) => {
return $$createType0($result);
});
}
// Private type creation functions
const $$createType0 = $models.UpdateResult.createFrom;

View File

@@ -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-wails/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();

View 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-wails/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;
}
}
}

View File

@@ -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";

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<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/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,33 @@
{
"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"
},
"dependencies": {
"@wailsio/runtime": "latest",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.3",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@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",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

1758
client/ui/frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,157 @@
:root {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: rgba(27, 38, 54, 1);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
src: local(""),
url("./Inter-Medium.ttf") format("truetype");
}
h3 {
font-size: 3em;
line-height: 1.1;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
button {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.result {
height: 20px;
line-height: 20px;
}
body {
margin: 0;
display: flex;
place-items: center;
place-content: center;
min-width: 320px;
min-height: 100vh;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
}
.logo:hover {
filter: drop-shadow(0 0 2em #e80000aa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
text-align: center;
}
.footer {
margin-top: 1rem;
align-content: center;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
color: black;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View 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>
);
}

View File

@@ -0,0 +1,45 @@
import { NavLink, Outlet } from "react-router-dom";
import { Activity, Bug, Network, Settings as SettingsIcon, Share2, Users } from "lucide-react";
import { cn } from "./lib/cn";
const nav = [
{ to: "/", label: "Status", icon: Activity, end: true },
{ to: "/peers", label: "Peers", icon: Share2 },
{ to: "/networks", label: "Networks", icon: Network },
{ to: "/profiles", label: "Profiles", icon: Users },
{ to: "/settings", label: "Settings", icon: SettingsIcon },
{ to: "/debug", label: "Debug", icon: Bug },
];
export default function Layout() {
return (
<div className="flex h-full">
<aside className="w-48 shrink-0 border-r border-nb-gray-200 bg-nb-gray-50 dark:border-nb-gray-800 dark:bg-nb-gray-940">
<div className="px-4 py-5 text-lg font-semibold text-netbird">NetBird</div>
<nav className="px-2">
{nav.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm",
isActive
? "bg-netbird/10 text-netbird"
: "text-nb-gray-700 hover:bg-nb-gray-100 dark:text-nb-gray-300 dark:hover:bg-nb-gray-900",
)
}
>
<Icon className="h-4 w-4" strokeWidth={1.5} />
{label}
</NavLink>
))}
</nav>
</aside>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "../lib/cn";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md";
const variants: Record<Variant, string> = {
primary: "bg-netbird text-white hover:bg-netbird-500 disabled:bg-nb-gray-300",
secondary:
"bg-nb-gray-100 text-nb-gray-900 hover:bg-nb-gray-200 dark:bg-nb-gray-900 dark:text-nb-gray-50 dark:hover:bg-nb-gray-800",
ghost:
"bg-transparent text-nb-gray-700 hover:bg-nb-gray-100 dark:text-nb-gray-200 dark:hover:bg-nb-gray-900",
danger: "bg-red-600 text-white hover:bg-red-500",
};
const sizes: Record<Size, string> = {
sm: "h-7 px-2 text-xs",
md: "h-9 px-3 text-sm",
};
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
{ variant = "primary", size = "md", className, ...rest },
ref,
) {
return (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60",
variants[variant],
sizes[size],
className,
)}
{...rest}
/>
);
});

View 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}
/>
);
}

View File

@@ -0,0 +1,33 @@
import { InputHTMLAttributes, forwardRef } from "react";
import { cn } from "../lib/cn";
interface Props extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export const Input = forwardRef<HTMLInputElement, Props>(function Input(
{ label, className, id, ...rest },
ref,
) {
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
return (
<div className="flex flex-col gap-1">
{label && (
<label htmlFor={inputId} className="text-xs font-medium text-nb-gray-600 dark:text-nb-gray-300">
{label}
</label>
)}
<input
id={inputId}
ref={ref}
className={cn(
"h-9 rounded-md border border-nb-gray-300 bg-white px-3 text-sm",
"focus:border-netbird focus:outline-none focus:ring-1 focus:ring-netbird",
"dark:border-nb-gray-700 dark:bg-nb-gray-925 dark:text-nb-gray-50",
className,
)}
{...rest}
/>
</div>
);
});

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import { Events } from "@wailsio/runtime";
import { Peers } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { Status } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
const EVENT_STATUS = "netbird:status";
// useStatus loads the current daemon status once and re-renders whenever the
// peers service emits a fresh snapshot over the Wails event bus.
export function useStatus(): { status: Status | null; error: string | null } {
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
Peers.Get()
.then((s) => {
if (!cancelled) setStatus(s);
})
.catch((e: unknown) => {
if (!cancelled) setError(String(e));
});
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
setStatus(ev.data);
setError(null);
});
return () => {
cancelled = true;
off();
};
}, []);
return { status, error };
}

View File

@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
}
body {
@apply bg-white text-nb-gray-900 antialiased;
}
.dark body {
@apply bg-nb-gray-950 text-nb-gray-50;
}

View 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));
}

View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,105 @@
import { useState } from "react";
import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Switch } from "../components/Switch";
import { Card } from "../components/Card";
export default function Debug() {
const [anonymize, setAnonymize] = useState(true);
const [systemInfo, setSystemInfo] = useState(true);
const [upload, setUpload] = useState(false);
const [uploadUrl, setUploadUrl] = useState("");
const [logFiles, setLogFiles] = useState(0);
const [running, setRunning] = useState(false);
const [result, setResult] = useState<DebugBundleResult | null>(null);
const [error, setError] = useState<string | null>(null);
const run = async () => {
setRunning(true);
setResult(null);
setError(null);
try {
const r = await DebugSvc.Bundle({
anonymize,
systemInfo,
uploadUrl: upload ? uploadUrl : "",
logFileCount: logFiles,
});
setResult(r);
} catch (e) {
setError(String(e));
} finally {
setRunning(false);
}
};
return (
<div className="space-y-4 p-6">
<h1 className="text-xl font-semibold">Debug bundle</h1>
<Card className="space-y-4">
<Switch
checked={anonymize}
onChange={setAnonymize}
label="Anonymize"
description="Replace IPs and identifiers in the bundle."
/>
<Switch
checked={systemInfo}
onChange={setSystemInfo}
label="Include system information"
/>
<Switch
checked={upload}
onChange={setUpload}
label="Upload on create"
/>
{upload && (
<Input
label="Upload URL"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
/>
)}
<Input
label="Log file count"
type="number"
value={logFiles}
onChange={(e) => setLogFiles(Number(e.target.value))}
/>
<div className="pt-2">
<Button onClick={run} disabled={running}>
{running ? "Generating…" : "Create bundle"}
</Button>
</div>
</Card>
{error && <p className="text-sm text-red-500">{error}</p>}
{result && (
<Card>
{result.path && (
<p className="text-sm">
<span className="text-nb-gray-500">Path:</span>{" "}
<span className="font-mono">{result.path}</span>
</p>
)}
{result.uploadedKey && (
<p className="text-sm">
<span className="text-nb-gray-500">Uploaded key:</span>{" "}
<span className="font-mono">{result.uploadedKey}</span>
</p>
)}
{result.uploadFailureReason && (
<p className="text-sm text-red-500">
Upload failed: {result.uploadFailureReason}
</p>
)}
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ExternalLink, Loader2, AlertTriangle, X, RotateCcw } from "lucide-react";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import { Button } from "../components/Button";
type Phase = "starting" | "browser" | "connecting" | "error";
export default function Login() {
const navigate = useNavigate();
const [phase, setPhase] = useState<Phase>("starting");
const [verificationUri, setVerificationUri] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
// attempt is bumped every time the user asks for a fresh start, which
// re-arms the useEffect below so the daemon's Login RPC is dialed again.
const [attempt, setAttempt] = useState(0);
const cancelledRef = useRef(false);
useEffect(() => {
cancelledRef.current = false;
setPhase("starting");
setVerificationUri("");
setErrorMsg("");
(async () => {
try {
const result = await Connection.Login({
profileName: "",
username: "",
managementUrl: "",
setupKey: "",
preSharedKey: "",
hostname: "",
hint: "",
});
if (cancelledRef.current) return;
if (result.needsSsoLogin) {
const uri = result.verificationUriComplete || result.verificationUri;
setVerificationUri(uri);
setPhase("browser");
if (uri) Connection.OpenURL(uri).catch(console.error);
await Connection.WaitSSOLogin({
userCode: result.userCode,
hostname: "",
});
if (cancelledRef.current) return;
}
setPhase("connecting");
await Connection.Up({ profileName: "", username: "" });
if (cancelledRef.current) return;
navigate("/", { replace: true });
} catch (e) {
if (cancelledRef.current) return;
setErrorMsg(String(e));
setPhase("error");
}
})();
return () => {
cancelledRef.current = true;
};
}, [navigate, attempt]);
// restart aborts any in-flight wait by toggling the cancellation flag,
// tells the daemon to drop whatever it's holding (a stale WaitSSOLogin
// can wedge the daemon for a previous UserCode), and then bumps attempt
// so the effect re-runs with a clean slate.
const restart = useCallback(async () => {
cancelledRef.current = true;
try {
await Connection.Down();
} catch (e) {
console.error(e);
}
setAttempt((n) => n + 1);
}, []);
// Cancel must also tell the daemon to abandon the in-flight WaitSSOLogin.
// Without Down(), the daemon stays parked on the OAuth flow's UserCode
// forever; subsequent Login calls re-use the cached flow but the user has
// no way out. Down() triggers the daemon's actCancel(), which unblocks
// WaitSSOLogin with a context-canceled error so our promise settles.
const cancel = useCallback(async () => {
cancelledRef.current = true;
try {
await Connection.Down();
} catch (e) {
console.error(e);
}
navigate("/", { replace: true });
}, [navigate]);
if (phase === "error") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<AlertTriangle className="h-8 w-8 text-red-500" strokeWidth={1.5} />
<h1 className="text-xl font-semibold">Login failed</h1>
<p className="max-w-sm break-words text-sm text-nb-gray-500">{errorMsg}</p>
<div className="flex gap-2">
<Button onClick={restart}>
<RotateCcw className="h-4 w-4" strokeWidth={1.5} /> Try again
</Button>
<Button variant="secondary" onClick={cancel}>
Back
</Button>
</div>
</div>
);
}
if (phase === "browser") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-xl font-semibold">Continue in your browser</h1>
<p className="max-w-sm text-sm text-nb-gray-500">
A browser tab should have opened. Sign in there this window will
continue automatically once you're done.
</p>
{verificationUri && (
<Button onClick={() => Connection.OpenURL(verificationUri).catch(console.error)}>
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
Reopen URL
</Button>
)}
<p className="max-w-sm break-all font-mono text-xs text-nb-gray-500">
{verificationUri}
</p>
<div className="flex items-center gap-2 text-sm text-nb-gray-500">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
Waiting for sign-in
</div>
<div className="flex gap-2 pt-2">
<Button variant="secondary" onClick={restart}>
<RotateCcw className="h-4 w-4" strokeWidth={1.5} /> Restart
</Button>
<Button variant="ghost" onClick={cancel}>
<X className="h-4 w-4" strokeWidth={1.5} /> Cancel
</Button>
</div>
</div>
);
}
const message =
phase === "connecting" ? "Bringing the connection up…" : "Starting login…";
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Loader2 className="h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
<p className="text-sm text-nb-gray-500">{message}</p>
<Button variant="ghost" onClick={cancel}>
<X className="h-4 w-4" strokeWidth={1.5} /> Cancel
</Button>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import { Button } from "../components/Button";
export default function LoginUrl() {
const [url, setUrl] = useState<string>("");
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split("?")[1] ?? "");
setUrl(params.get("url") ?? "");
}, []);
if (!url) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-nb-gray-500">
No login URL provided.
</div>
);
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-xl font-semibold">Continue in your browser</h1>
<p className="max-w-sm text-sm text-nb-gray-500">
Open the following URL to finish signing in.
</p>
<Button onClick={() => Connection.OpenURL(url).catch(console.error)}>
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
Open URL
</Button>
<p className="max-w-sm break-all font-mono text-xs text-nb-gray-500">{url}</p>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw } from "lucide-react";
import { Networks as NetworksSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { Network } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Tabs } from "../components/Tabs";
export default function Networks() {
const [routes, setRoutes] = useState<Network[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const list = await NetworksSvc.List();
setRoutes(list);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const toggle = async (id: string, selected: boolean) => {
try {
if (selected) {
await NetworksSvc.Deselect({ networkIds: [id], append: false, all: false });
} else {
await NetworksSvc.Select({ networkIds: [id], append: true, all: false });
}
await refresh();
} catch (e) {
setError(String(e));
}
};
const setAll = async (ids: string[], on: boolean) => {
try {
if (on) {
await NetworksSvc.Select({ networkIds: ids, append: false, all: true });
} else {
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: true });
}
await refresh();
} catch (e) {
setError(String(e));
}
};
const overlapping = useMemo(() => filterOverlapping(routes), [routes]);
const exitNodes = useMemo(() => routes.filter((r) => r.range === "0.0.0.0/0"), [routes]);
return (
<div className="flex h-full flex-col p-6">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-xl font-semibold">Networks</h1>
<Button variant="secondary" size="sm" onClick={refresh} disabled={loading}>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} strokeWidth={1.5} />
Refresh
</Button>
</div>
{error && (
<p className="mb-2 text-sm text-red-500">{error}</p>
)}
<div className="flex-1 overflow-hidden">
<Tabs
tabs={[
{
value: "all",
label: `All (${routes.length})`,
content: <NetworkList routes={routes} onToggle={toggle} onSetAll={setAll} />,
},
{
value: "overlap",
label: `Overlapping (${overlapping.length})`,
content: <NetworkList routes={overlapping} onToggle={toggle} onSetAll={setAll} />,
},
{
value: "exit",
label: `Exit-node (${exitNodes.length})`,
content: <NetworkList routes={exitNodes} onToggle={toggle} onSetAll={setAll} />,
},
]}
/>
</div>
</div>
);
}
function NetworkList({
routes,
onToggle,
onSetAll,
}: {
routes: Network[];
onToggle: (id: string, selected: boolean) => void;
onSetAll: (ids: string[], on: boolean) => void;
}) {
if (routes.length === 0) {
return <p className="p-4 text-sm text-nb-gray-500">No networks.</p>;
}
const ids = routes.map((r) => r.id);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 gap-2 border-b border-nb-gray-200 px-4 py-2 dark:border-nb-gray-800">
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, true)}>
Select all
</Button>
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, false)}>
Deselect all
</Button>
</div>
<ul className="flex-1 overflow-auto divide-y divide-nb-gray-200 dark:divide-nb-gray-800">
{routes.map((r) => (
<li key={r.id} className="flex items-start gap-3 px-4 py-3">
<input
type="checkbox"
checked={r.selected}
onChange={() => onToggle(r.id, r.selected)}
className="mt-1 h-4 w-4 accent-netbird"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{r.id}</p>
<p className="truncate font-mono text-xs text-nb-gray-500">{r.range}</p>
{r.domains.length > 0 && (
<p className="mt-0.5 truncate text-xs text-nb-gray-500">
{r.domains.join(", ")}
</p>
)}
</div>
</li>
))}
</ul>
</div>
);
}
function filterOverlapping(routes: Network[]): Network[] {
const byRange = new Map<string, Network[]>();
for (const r of routes) {
if (r.domains.length > 0) continue;
const arr = byRange.get(r.range) ?? [];
arr.push(r);
byRange.set(r.range, arr);
}
const out: Network[] = [];
for (const arr of byRange.values()) {
if (arr.length > 1) out.push(...arr);
}
return out;
}

View File

@@ -0,0 +1,211 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react";
import { useStatus } from "../hooks/useStatus";
import type { PeerStatus } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Card } from "../components/Card";
import { Input } from "../components/Input";
import { cn } from "../lib/cn";
export default function Peers() {
const { status } = useStatus();
const [filter, setFilter] = useState("");
const [expanded, setExpanded] = useState<string | null>(null);
const peers = useMemo(() => {
const all = status?.peers ?? [];
if (!filter.trim()) return all;
const q = filter.trim().toLowerCase();
return all.filter(
(p) =>
p.fqdn.toLowerCase().includes(q) ||
p.ip.toLowerCase().includes(q) ||
p.networks.some((n) => n.toLowerCase().includes(q)),
);
}, [status?.peers, filter]);
return (
<div className="flex h-full flex-col p-6">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-xl font-semibold">
Peers
<span className="ml-2 text-sm font-normal text-nb-gray-500">
{status?.peers?.length ?? 0}
</span>
</h1>
<div className="w-64">
<Input
placeholder="Filter by FQDN / IP / network"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
{peers.length === 0 ? (
<Card className="text-sm text-nb-gray-500">
{status?.peers?.length === 0
? "No peers visible from this client."
: "No peers match the filter."}
</Card>
) : (
<ul className="flex-1 divide-y divide-nb-gray-200 overflow-auto rounded-lg border border-nb-gray-200 dark:divide-nb-gray-800 dark:border-nb-gray-800">
{peers.map((p) => (
<PeerRow
key={p.pubKey}
peer={p}
expanded={expanded === p.pubKey}
onToggle={() => setExpanded(expanded === p.pubKey ? null : p.pubKey)}
/>
))}
</ul>
)}
</div>
);
}
function PeerRow({
peer,
expanded,
onToggle,
}: {
peer: PeerStatus;
expanded: boolean;
onToggle: () => void;
}) {
return (
<li>
<button
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-nb-gray-50 dark:hover:bg-nb-gray-940"
>
<ChevronIcon expanded={expanded} />
<StateBadge state={peer.connStatus} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{peer.fqdn || "—"}</p>
<p className="truncate font-mono text-xs text-nb-gray-500">{peer.ip}</p>
</div>
<RouteIcon relayed={peer.relayed} connected={peer.connStatus === "Connected"} />
{peer.rosenpassEnabled && (
<ShieldCheck className="h-4 w-4 text-green-500" strokeWidth={1.5} />
)}
<span className="w-16 text-right text-xs text-nb-gray-500">
{peer.connStatus === "Connected" && peer.latencyMs > 0
? `${peer.latencyMs} ms`
: ""}
</span>
</button>
{expanded && <PeerDetails peer={peer} />}
</li>
);
}
function PeerDetails({ peer }: { peer: PeerStatus }) {
return (
<div className="grid grid-cols-2 gap-x-6 gap-y-2 bg-nb-gray-50 px-12 py-3 text-xs dark:bg-nb-gray-940">
<Detail label="Public key" value={peer.pubKey} mono />
<Detail label="Last handshake" value={fmtRelative(peer.lastHandshakeUnix)} />
<Detail label="Status since" value={fmtRelative(peer.connStatusUpdateUnix)} />
<Detail
label="Bytes rx / tx"
value={`${fmtBytes(peer.bytesRx)} / ${fmtBytes(peer.bytesTx)}`}
/>
<Detail
label="Local candidate"
value={
peer.localIceCandidateType
? `${peer.localIceCandidateType} (${peer.localIceCandidateEndpoint || "—"})`
: "—"
}
mono
/>
<Detail
label="Remote candidate"
value={
peer.remoteIceCandidateType
? `${peer.remoteIceCandidateType} (${peer.remoteIceCandidateEndpoint || "—"})`
: "—"
}
mono
/>
{peer.relayed && (
<Detail label="Relay" value={peer.relayAddress || "—"} mono />
)}
{peer.networks.length > 0 && (
<Detail label="Networks" value={peer.networks.join(", ")} />
)}
</div>
);
}
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex min-w-0 gap-2">
<span className="shrink-0 text-nb-gray-500">{label}</span>
<span
className={cn(
"min-w-0 truncate text-nb-gray-700 dark:text-nb-gray-200",
mono && "font-mono",
)}
>
{value}
</span>
</div>
);
}
function ChevronIcon({ expanded }: { expanded: boolean }) {
const Icon = expanded ? ChevronDown : ChevronRight;
return <Icon className="h-4 w-4 shrink-0 text-nb-gray-400" strokeWidth={1.5} />;
}
function StateBadge({ state }: { state: string }) {
const cls = "h-2 w-2 rounded-full shrink-0";
switch (state) {
case "Connected":
return <span className={cn(cls, "bg-green-500")} title="Connected" />;
case "Connecting":
return <span className={cn(cls, "bg-netbird animate-pulse")} title="Connecting" />;
case "Idle":
return <span className={cn(cls, "bg-yellow-500")} title="Idle" />;
default:
return <span className={cn(cls, "bg-nb-gray-400")} title={state || "Disconnected"} />;
}
}
function RouteIcon({ relayed, connected }: { relayed: boolean; connected: boolean }) {
if (!connected) {
return <span className="w-4 shrink-0" />;
}
if (relayed) {
return (
<Network
className="h-4 w-4 text-yellow-600"
strokeWidth={1.5}
>
<title>Relayed</title>
</Network>
);
}
return (
<Zap className="h-4 w-4 text-green-600" strokeWidth={1.5}>
<title>P2P</title>
</Zap>
);
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function fmtRelative(unixSec: number): string {
if (!unixSec) return "—";
const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
if (ageSec < 60) return `${ageSec}s ago`;
if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m ago`;
if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h ago`;
return `${Math.floor(ageSec / 86400)}d ago`;
}

View File

@@ -0,0 +1,173 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import { Plus, RefreshCw } from "lucide-react";
import {
Profiles as ProfilesSvc,
Connection,
} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Card } from "../components/Card";
export default function Profiles() {
const [username, setUsername] = useState("");
const [profiles, setProfiles] = useState<Profile[]>([]);
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const refresh = useCallback(async () => {
try {
const u = username || (await ProfilesSvc.Username());
if (!username) setUsername(u);
const list = await ProfilesSvc.List(u);
setProfiles(list);
setError(null);
} catch (e) {
setError(String(e));
}
}, [username]);
useEffect(() => {
refresh();
}, [refresh]);
const select = async (name: string) => {
try {
await ProfilesSvc.Switch({ profileName: name, username });
await Connection.Up({ profileName: name, username });
await refresh();
} catch (e) {
setError(String(e));
}
};
const deregister = async (name: string) => {
try {
await Connection.Logout({ profileName: name, username });
await refresh();
} catch (e) {
setError(String(e));
}
};
const remove = async (name: string) => {
if (name === "default") return;
try {
await ProfilesSvc.Remove({ profileName: name, username });
await refresh();
} catch (e) {
setError(String(e));
}
};
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Profiles</h1>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={refresh}>
<RefreshCw className="h-3.5 w-3.5" strokeWidth={1.5} /> Refresh
</Button>
<Button size="sm" onClick={() => setAdding(true)}>
<Plus className="h-3.5 w-3.5" strokeWidth={1.5} /> Add
</Button>
</div>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="space-y-2">
{profiles.map((p) => (
<Card key={p.name} className="flex items-center gap-3">
<input
type="radio"
name="active-profile"
checked={p.isActive}
onChange={() => select(p.name)}
className="h-4 w-4 accent-netbird"
/>
<div className="flex-1">
<p className="text-sm font-medium">{p.name}</p>
{p.isActive && <p className="text-xs text-nb-gray-500">Active</p>}
</div>
<Button size="sm" variant="ghost" onClick={() => deregister(p.name)}>
Deregister
</Button>
<Button
size="sm"
variant="danger"
disabled={p.name === "default"}
onClick={() => remove(p.name)}
>
Remove
</Button>
</Card>
))}
{profiles.length === 0 && (
<p className="text-sm text-nb-gray-500">No profiles.</p>
)}
</div>
{adding && (
<AddDialog
username={username}
onClose={() => setAdding(false)}
onAdded={async () => {
setAdding(false);
await refresh();
}}
/>
)}
</div>
);
}
function AddDialog({
username,
onClose,
onAdded,
}: {
username: string;
onClose: () => void;
onAdded: () => void;
}) {
const [name, setName] = useState("");
const [err, setErr] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await ProfilesSvc.Add({ profileName: name.trim(), username });
onAdded();
} catch (e) {
setErr(String(e));
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<form
onSubmit={submit}
className="w-80 rounded-lg border border-nb-gray-200 bg-white p-4 shadow-lg dark:border-nb-gray-800 dark:bg-nb-gray-925"
>
<h2 className="mb-3 text-base font-semibold">New profile</h2>
<Input
autoFocus
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{err && <p className="mt-2 text-xs text-red-500">{err}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button type="submit" size="sm">
Add
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { CheckCircle2, Circle, Loader2, Power } from "lucide-react";
import { useStatus } from "../hooks/useStatus";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import { Button } from "../components/Button";
import { cn } from "../lib/cn";
export default function QuickActions() {
const { status } = useStatus();
const state = status?.status ?? "Disconnected";
const connected = state === "Connected";
const connecting = state === "Connecting";
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
<Icon state={state} />
<p className="text-lg font-medium">{state}</p>
{connected ? (
<Button variant="secondary" onClick={() => Connection.Down()}>
Disconnect
</Button>
) : (
<Button onClick={() => Connection.Up({ profileName: "", username: "" })} disabled={connecting}>
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
</Button>
)}
</div>
);
}
function Icon({ state }: { state: string }) {
const cls = "h-12 w-12";
switch (state) {
case "Connected":
return <CheckCircle2 className={cn(cls, "text-green-500")} strokeWidth={1.5} />;
case "Connecting":
return <Loader2 className={cn(cls, "animate-spin text-netbird")} strokeWidth={1.5} />;
default:
return <Circle className={cn(cls, "text-nb-gray-400")} strokeWidth={1.5} />;
}
}

View File

@@ -0,0 +1,240 @@
import { useCallback, useEffect, useState } from "react";
import {
Settings as SettingsSvc,
Profiles as ProfilesSvc,
} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { Config } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Switch } from "../components/Switch";
import { Tabs } from "../components/Tabs";
interface Ctx {
cfg: Config;
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
}
export default function Settings() {
const [username, setUsername] = useState("");
const [profile, setProfile] = useState("");
const [cfg, setCfg] = useState<Config | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const u = await ProfilesSvc.Username();
const active = await ProfilesSvc.GetActive();
const profileName = active.profileName || "default";
setUsername(u);
setProfile(profileName);
const c = await SettingsSvc.GetConfig({ profileName, username: u });
setCfg(c);
setError(null);
} catch (e) {
setError(String(e));
}
}, []);
useEffect(() => {
load();
}, [load]);
const setField: Ctx["setField"] = (k, v) => {
setCfg((c) => (c ? { ...c, [k]: v } : c));
};
const save = async () => {
if (!cfg) return;
setSaving(true);
try {
await SettingsSvc.SetConfig({
profileName: profile,
username,
managementUrl: cfg.managementUrl,
adminUrl: cfg.adminUrl,
interfaceName: cfg.interfaceName,
wireguardPort: cfg.wireguardPort,
mtu: cfg.mtu,
preSharedKey: cfg.preSharedKey,
disableAutoConnect: cfg.disableAutoConnect,
serverSshAllowed: cfg.serverSshAllowed,
rosenpassEnabled: cfg.rosenpassEnabled,
rosenpassPermissive: cfg.rosenpassPermissive,
disableNotifications: cfg.disableNotifications,
lazyConnectionEnabled: cfg.lazyConnectionEnabled,
blockInbound: cfg.blockInbound,
networkMonitor: cfg.networkMonitor,
disableClientRoutes: cfg.disableClientRoutes,
disableServerRoutes: cfg.disableServerRoutes,
disableDns: cfg.disableDns,
blockLanAccess: cfg.blockLanAccess,
enableSshRoot: cfg.enableSshRoot,
enableSshSftp: cfg.enableSshSftp,
enableSshLocalPortForwarding: cfg.enableSshLocalPortForwarding,
enableSshRemotePortForwarding: cfg.enableSshRemotePortForwarding,
disableSshAuth: cfg.disableSshAuth,
sshJwtCacheTtl: cfg.sshJwtCacheTtl,
});
setError(null);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
if (!cfg) {
return <div className="p-6 text-sm text-nb-gray-500">Loading</div>;
}
const ctx: Ctx = { cfg, setField };
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-nb-gray-200 px-6 py-3 dark:border-nb-gray-800">
<h1 className="text-xl font-semibold">Settings</h1>
<Button onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
{error && <p className="px-6 py-2 text-sm text-red-500">{error}</p>}
<div className="flex-1 overflow-hidden">
<Tabs
tabs={[
{ value: "conn", label: "Connection", content: <ConnectionTab {...ctx} /> },
{ value: "net", label: "Network", content: <NetworkTab {...ctx} /> },
{ value: "ssh", label: "SSH", content: <SSHTab {...ctx} /> },
]}
/>
</div>
</div>
);
}
function ConnectionTab({ cfg, setField }: Ctx) {
return (
<div className="grid max-w-2xl gap-4 p-6">
<Input
label="Management URL"
value={cfg.managementUrl}
onChange={(e) => setField("managementUrl", e.target.value)}
/>
<Input
label="Pre-shared key"
type="password"
value={cfg.preSharedKey}
onChange={(e) => setField("preSharedKey", e.target.value)}
/>
<Input
label="Interface name"
value={cfg.interfaceName}
onChange={(e) => setField("interfaceName", e.target.value)}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="WireGuard port"
type="number"
value={cfg.wireguardPort}
onChange={(e) => setField("wireguardPort", Number(e.target.value))}
/>
<Input
label="MTU"
type="number"
value={cfg.mtu}
onChange={(e) => setField("mtu", Number(e.target.value))}
/>
</div>
<Switch
checked={cfg.rosenpassEnabled}
onChange={(v) => setField("rosenpassEnabled", v)}
label="Rosenpass (post-quantum)"
/>
<Switch
checked={cfg.rosenpassPermissive}
onChange={(v) => setField("rosenpassPermissive", v)}
label="Rosenpass permissive mode"
/>
</div>
);
}
function NetworkTab({ cfg, setField }: Ctx) {
return (
<div className="grid max-w-xl gap-4 p-6">
<Switch
checked={cfg.networkMonitor}
onChange={(v) => setField("networkMonitor", v)}
label="Network monitor"
/>
<Switch
checked={cfg.disableDns}
onChange={(v) => setField("disableDns", v)}
label="Disable DNS"
/>
<Switch
checked={cfg.disableClientRoutes}
onChange={(v) => setField("disableClientRoutes", v)}
label="Disable client routes"
/>
<Switch
checked={cfg.disableServerRoutes}
onChange={(v) => setField("disableServerRoutes", v)}
label="Disable server routes"
/>
<Switch
checked={cfg.blockLanAccess}
onChange={(v) => setField("blockLanAccess", v)}
label="Block LAN access"
/>
<Switch
checked={cfg.blockInbound}
onChange={(v) => setField("blockInbound", v)}
label="Block inbound connections"
/>
</div>
);
}
function SSHTab({ cfg, setField }: Ctx) {
return (
<div className="grid max-w-xl gap-4 p-6">
<Switch
checked={cfg.serverSshAllowed}
onChange={(v) => setField("serverSshAllowed", v)}
label="Server SSH allowed"
/>
<Switch
checked={cfg.enableSshRoot}
onChange={(v) => setField("enableSshRoot", v)}
label="SSH root login"
/>
<Switch
checked={cfg.enableSshSftp}
onChange={(v) => setField("enableSshSftp", v)}
label="SFTP"
/>
<Switch
checked={cfg.enableSshLocalPortForwarding}
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
label="Local port forwarding"
/>
<Switch
checked={cfg.enableSshRemotePortForwarding}
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
label="Remote port forwarding"
/>
<Switch
checked={cfg.disableSshAuth}
onChange={(v) => setField("disableSshAuth", v)}
label="Disable SSH auth"
/>
<Input
label="JWT cache TTL (seconds)"
type="number"
value={cfg.sshJwtCacheTtl}
onChange={(e) => setField("sshJwtCacheTtl", Number(e.target.value))}
/>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useStatus } from "../hooks/useStatus";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { cn } from "../lib/cn";
export default function Status() {
const { status, error } = useStatus();
const navigate = useNavigate();
const connState = status?.status ?? "Disconnected";
const connected = connState === "Connected";
const connecting = connState === "Connecting";
// The daemon reports "NeedsLogin" on a fresh install or after a session
// expires; "SessionExpired" once a previously good session lapses. In both
// cases Connect would fail without a fresh SSO login.
const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired";
// Always offer Login while we aren't Connected — including Connecting,
// because a stuck Login on the daemon leaves us in Connecting forever and
// the user has no other way out. Disconnect is the manual unstick path.
const showLogin = !connected;
const login = () => navigate("/login");
const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error);
const disconnect = () => Connection.Down().catch(console.error);
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StateIcon state={connState} />
<div>
<h1 className="text-xl font-semibold leading-none">{connState}</h1>
<p className="mt-1 text-sm text-nb-gray-500">
{status?.local.fqdn || "—"}
</p>
</div>
</div>
<div className="flex gap-2">
{needsLogin ? (
<Button onClick={login}>
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
</Button>
) : (
<Button onClick={connect} disabled={connected || connecting}>
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
</Button>
)}
{showLogin && !needsLogin && (
<Button onClick={login} variant="secondary">
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
</Button>
)}
<Button onClick={disconnect} variant="secondary" disabled={!connected}>
Disconnect
</Button>
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
<AlertTriangle className="mt-0.5 h-4 w-4" strokeWidth={1.5} />
<span>{error}</span>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<InfoCard label="Local IP" value={status?.local.ip || "—"} />
<InfoCard label="Peers" value={String(status?.peers?.length ?? 0)} />
<LinkCard label="Management" link={status?.management} />
<LinkCard label="Signal" link={status?.signal} />
</div>
<Card>
<h2 className="mb-3 text-sm font-semibold text-nb-gray-700 dark:text-nb-gray-200">
Recent events
</h2>
{(() => {
const events = dedupEvents(status?.events ?? []).slice(0, 8);
if (events.length === 0) {
return <p className="text-sm text-nb-gray-500">No recent events.</p>;
}
return (
<ul className="space-y-2 text-sm">
{events.map((e, i) => (
<li key={`${e.id}-${i}`} className="flex gap-2">
<span className="shrink-0 font-mono text-xs text-nb-gray-500">
{e.severity}
</span>
<span className="text-nb-gray-700 dark:text-nb-gray-200">
{e.userMessage || e.message}
</span>
</li>
))}
</ul>
);
})()}
</Card>
</div>
);
}
function StateIcon({ state }: { state: string }) {
const cls = "h-7 w-7";
switch (state) {
case "Connected":
return <CheckCircle2 className={cn(cls, "text-green-500")} strokeWidth={1.5} />;
case "Connecting":
return <Loader2 className={cn(cls, "animate-spin text-netbird")} strokeWidth={1.5} />;
case "Error":
return <AlertTriangle className={cn(cls, "text-red-500")} strokeWidth={1.5} />;
default:
return <Circle className={cn(cls, "text-nb-gray-400")} strokeWidth={1.5} />;
}
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<Card>
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
<p className="mt-1 truncate font-mono text-sm">{value}</p>
</Card>
);
}
// dedupEvents collapses repeated daemon events that carry the same logical
// content. The daemon emits one "new_version_available" event per check tick,
// so its 10-event ring buffer fills with duplicates after a quiet hour. Same
// goes for periodic "DNS unreachable" or "auth retry" events. We key by
// message + a small set of identity-bearing metadata fields and keep the
// newest occurrence (the events array is already in publish order).
function dedupEvents(events: SystemEvent[]): SystemEvent[] {
const seen = new Set<string>();
const out: SystemEvent[] = [];
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
const md = e.metadata ?? {};
const key = [
e.severity,
e.category,
e.userMessage || e.message,
md["new_version_available"] ?? "",
md["enforced"] ?? "",
].join("|");
// eslint-disable-next-line no-console
console.log("[dedup]", { key, event: e });
if (seen.has(key)) continue;
seen.add(key);
out.unshift(e);
}
return out;
}
function LinkCard({
label,
link,
}: {
label: string;
link?: { url: string; connected: boolean; error?: string };
}) {
return (
<Card>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
<span
className={cn(
"h-2 w-2 rounded-full",
link?.connected ? "bg-green-500" : "bg-nb-gray-400",
)}
/>
</div>
<p className="mt-1 truncate text-xs text-nb-gray-600 dark:text-nb-gray-300">
{link?.url || "—"}
</p>
{link?.error && (
<p className="mt-1 truncate text-xs text-red-500">{link.error}</p>
)}
</Card>
);
}

View File

@@ -0,0 +1,135 @@
import { useEffect, useRef, useState } from "react";
import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
const TIMEOUT_MS = 15 * 60 * 1000;
const POLL_INTERVAL_MS = 2000;
// How long the daemon is allowed to be unreachable before we treat it as
// "daemon went down for the upgrade, treat as success and quit". Mirrors
// the legacy Fyne UI's branch in client/ui/update.go where a connection
// failure during polling is taken as the success signal.
const DAEMON_DOWN_GRACE_MS = 5000;
type Phase =
| { kind: "running"; dots: number }
| { kind: "timeout" }
| { kind: "canceled" }
| { kind: "failed"; message: string };
export default function Update() {
const [phase, setPhase] = useState<Phase>({ kind: "running", dots: 1 });
const phaseRef = useRef(phase);
phaseRef.current = phase;
const version = new URLSearchParams(
window.location.hash.split("?")[1] ?? "",
).get("version");
useEffect(() => {
let cancelled = false;
const start = Date.now();
let firstUnreachableAt: number | null = null;
UpdateSvc.Trigger().catch(() => {
// The daemon may already be down (installer launched, daemon shutting
// down). Don't treat as failure here; the poll loop's daemon-down
// detection handles it.
});
const dotTimer = setInterval(() => {
if (cancelled) return;
setPhase((p) =>
p.kind === "running" ? { kind: "running", dots: (p.dots % 3) + 1 } : p,
);
}, 1000);
const pollTimer = setInterval(async () => {
if (cancelled) return;
if (phaseRef.current.kind !== "running") return;
if (Date.now() - start > TIMEOUT_MS) {
clearInterval(pollTimer);
clearInterval(dotTimer);
setPhase({ kind: "timeout" });
return;
}
try {
const r = await UpdateSvc.GetInstallerResult();
firstUnreachableAt = null;
if (r.success) {
clearInterval(pollTimer);
clearInterval(dotTimer);
UpdateSvc.Quit();
return;
}
if (r.errorMsg) {
clearInterval(pollTimer);
clearInterval(dotTimer);
setPhase(mapInstallError(r.errorMsg));
}
} catch {
// RPC failed. The daemon often goes away mid-upgrade — treat a
// sustained outage as success and quit, matching the legacy UI.
const now = Date.now();
if (firstUnreachableAt === null) {
firstUnreachableAt = now;
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
clearInterval(pollTimer);
clearInterval(dotTimer);
UpdateSvc.Quit();
}
}
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(dotTimer);
clearInterval(pollTimer);
};
}, []);
const versionLine = version
? `Updating client to: ${version}.`
: "Updating client.";
return (
<div className="flex h-full items-center justify-center p-6">
<div className="space-y-3 text-center">
<p className="whitespace-pre-line text-sm text-nb-gray-700 dark:text-nb-gray-200">
{`Your client version is older than the auto-update version set in Management.\n${versionLine}`}
</p>
<p className="text-base font-medium">{statusText(phase)}</p>
</div>
</div>
);
}
function statusText(p: Phase): string {
switch (p.kind) {
case "running":
return "Updating" + ".".repeat(p.dots);
case "timeout":
return "Update timed out. Please try again.";
case "canceled":
return "Update canceled.";
case "failed":
return "Update failed: " + p.message;
}
}
// Mirrors mapInstallError in client/ui/update.go. The daemon's installer
// surfaces error strings rather than typed errors, so the UI sniffs the
// message to decide whether to show the timeout/canceled wording.
function mapInstallError(msg: string): Phase {
const m = msg.trim().toLowerCase();
if (m === "") {
return { kind: "failed", message: "unknown update error" };
}
if (m.includes("deadline exceeded") || m.includes("timeout")) {
return { kind: "timeout" };
}
if (m.includes("canceled") || m.includes("cancelled")) {
return { kind: "canceled" };
}
return { kind: "failed", message: msg };
}

1
client/ui/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,44 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
netbird: {
DEFAULT: "#f68330",
50: "#fff6ed",
100: "#feecd6",
200: "#ffd4a6",
300: "#fab677",
400: "#f68330",
500: "#f46d1b",
600: "#e55311",
700: "#be3e10",
800: "#973215",
900: "#7a2b14",
},
"nb-gray": {
DEFAULT: "#181A1D",
50: "#f4f6f7",
100: "#e4e7e9",
200: "#cbd2d6",
300: "#a3adb5",
400: "#7c8994",
500: "#616e79",
600: "#535d67",
700: "#474e57",
800: "#3f444b",
900: "#2e3238",
925: "#1e2123",
940: "#1c1e21",
950: "#181a1d",
},
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": false,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "bindings"],
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import wails from "@wailsio/runtime/plugins/vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), wails("./bindings")],
server: {
port: 9245,
strictPort: true,
},
});