Compare commits

..

36 Commits

Author SHA1 Message Date
pascal
373f014aea Merge branch 'feature/check-cert-locker-before-acme' into test/proxy-fixes 2026-03-09 17:24:31 +01:00
pascal
2df3fb959b Merge branch 'feature/domain-activity-events' into test/proxy-fixes 2026-03-09 17:23:54 +01:00
pascal
14b0e9462b Merge branch 'feature/move-proxy-to-opentelemetry' into test/proxy-fixes 2026-03-09 17:23:42 +01:00
pascal
8db71b545e add certificate issue duration metrics 2026-03-09 16:59:33 +01:00
pascal
7c3532d8e5 fix test 2026-03-09 16:18:49 +01:00
pascal
1aa1eef2c5 account for streaming 2026-03-09 16:08:30 +01:00
pascal
d9418ddc1e do log throughput and requests. Also add throughput to the log entries 2026-03-09 15:39:29 +01:00
pascal
1b4c831976 add activity events for domains 2026-03-09 14:18:05 +01:00
pascal
a19611d8e0 check the cert locker before starting the acme challenge 2026-03-09 13:40:41 +01:00
pascal
9ab6138040 fix mapping counting and metrics registry 2026-03-09 13:17:50 +01:00
Pascal Fischer
30c02ab78c [management] use the cache for the pkce state (#5516) 2026-03-09 12:23:06 +01:00
Zoltan Papp
3acd86e346 [client] "reset connection" error on wake from sleep (#5522)
Capture engine reference before actCancel() in cleanupConnection().

After actCancel(), the connectWithRetryRuns goroutine sets engine to nil,
causing connectClient.Stop() to skip shutdown. This allows the goroutine
to set ErrResetConnection on the shared state after Down() clears it,
causing the next Up() to fail.
2026-03-09 10:25:51 +01:00
pascal
c2fec57c0f switch proxy to use opentelemetry 2026-03-07 11:16:40 +01:00
Pascal Fischer
5c20f13c48 [management] fix domain uniqueness (#5529) 2026-03-07 10:46:37 +01:00
Pascal Fischer
e6587b071d [management] use realip for proxy registration (#5525) 2026-03-06 16:11:44 +01:00
Maycon Santos
85451ab4cd [management] Add stable domain resolution for combined server (#5515)
The combined server was using the hostname from exposedAddress for both
singleAccountModeDomain and dnsDomain, causing fresh installs to get
the wrong domain and existing installs to break if the config changed.
 Add resolveDomains() to BaseServer that reads domain from the store:
  - Fresh install (0 accounts): uses "netbird.selfhosted" default
  - Existing install: reads persisted domain from the account in DB
  - Store errors: falls back to default safely

The combined server opts in via AutoResolveDomains flag, while the
 standalone management server is unaffected.
2026-03-06 08:43:46 +01:00
Pascal Fischer
a7f3ba03eb [management] aggregate grpc metrics by accountID (#5486) 2026-03-05 22:10:45 +01:00
Maycon Santos
4f0a3a77ad [management] Avoid breaking single acc mode when switching domains (#5511)
* **Bug Fixes**
  * Fixed domain configuration handling in single account mode to properly retrieve and apply domain settings from account data.
  * Improved error handling when account data is unavailable with fallback to configured default domain.

* **Tests**
  * Added comprehensive test coverage for single account mode domain configuration scenarios, including edge cases for missing or unavailable account data.
2026-03-05 14:30:31 +01:00
Maycon Santos
44655ca9b5 [misc] add PR title validation workflow (#5503) 2026-03-05 11:43:18 +01:00
Viktor Liu
e601278117 [management,proxy] Add per-target options to reverse proxy (#5501) 2026-03-05 10:03:26 +01:00
Maycon Santos
8e7b016be2 [management] Replace in-memory expose tracker with SQL-backed operations (#5494)
The expose tracker used sync.Map for in-memory TTL tracking of active expose sessions, which broke and lost all sessions on restart.

Replace with SQL-backed operations that reuse the existing meta_last_renewed_at column:

- Add store methods: RenewEphemeralService, GetExpiredEphemeralServices, CountEphemeralServicesByPeer, EphemeralServiceExists
- Move duplicate/limit checks inside a transaction with row-level locking (SELECT ... FOR UPDATE) to prevent concurrent bypass
- Reaper re-checks expiry under row lock to avoid deleting a just-renewed service and prevent duplicate event emission 
- Add composite index on (source, source_peer) for efficient queries
- Batch-limit and column-select the reaper query to avoid DB/GC spikes
- Filter out malformed rows with empty source_peer
2026-03-04 18:15:13 +01:00
Maycon Santos
9e01ea7aae [misc] Add ISSUE_TEMPLATE configuration file (#5500)
Add issue template config file  with support and troubleshooting links
2026-03-04 14:30:54 +01:00
hbzhost
cfc7ec8bb9 [client] Fix SSH JWT auth failure with Azure Entra ID iat backdating (#5471)
Increase DefaultJWTMaxTokenAge from 5 to 10 minutes to accommodate
identity providers like Azure Entra ID that backdate the iat claim
by up to 5 minutes, causing tokens to be immediately rejected.

Fixes #5449

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 14:11:14 +01:00
Misha Bragin
b3bbc0e5c6 Fix embedded IdP metrics to count local and generic OIDC users (#5498) 2026-03-04 12:34:11 +02:00
Pascal Fischer
d7c8e37ff4 [management] Store connected proxies in DB (#5472)
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
2026-03-03 18:39:46 +01:00
Zoltan Papp
05b66e73bc [client] Fix deadlock in route peer status watcher (#5489)
Wrap peerStateUpdate send in a nested select to prevent goroutine
blocking when the consumer has exited, which could fill the
subscription buffer and deadlock the Status mutex.
2026-03-03 13:50:46 +01:00
Jeremie Deray
01ceedac89 [client] Fix profile config directory permissions (#5457)
* fix user profile dir perm

* fix fileExists

* revert return var change

* fix anti-pattern
2026-03-03 13:48:51 +01:00
Misha Bragin
403babd433 [self-hosted] specify sql file location of auth, activity and main store (#5487) 2026-03-03 12:53:16 +02:00
Maycon Santos
47133031e5 [client] fix: client/Dockerfile to reduce vulnerabilities (#5217)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2026-03-03 08:44:08 +01:00
Pascal Fischer
82da606886 [management] Add explicit target delete on service removal (#5420) 2026-03-02 18:25:44 +01:00
Viktor Liu
bbe5ae2145 [client] Flush buffer immediately to support gprc (#5469) 2026-03-02 15:17:08 +01:00
Viktor Liu
0b21498b39 [client] Fix close of closed channel panic in ConnectClient retry loop (#5470) 2026-03-02 10:07:53 +01:00
Viktor Liu
0ca59535f1 [management] Add reverse proxy services REST client (#5454) 2026-02-28 13:04:58 +08:00
Misha Bragin
59c77d0658 [self-hosted] support embedded IDP postgres db (#5443)
* Add postgres config for embedded idp

Entire-Checkpoint: 9ace190c1067

* Rename idpStore to authStore

Entire-Checkpoint: 73a896c79614

* Fix review notes

Entire-Checkpoint: 6556783c0df3

* Don't accept pq port = 0

Entire-Checkpoint: 80d45e37782f

* Optimize configs

Entire-Checkpoint: 80d45e37782f

* Fix lint issues

Entire-Checkpoint: 3eec968003d1

* Fail fast on combined postgres config

Entire-Checkpoint: b17839d3d8c6

* Simplify management config method

Entire-Checkpoint: 0f083effa20e
2026-02-27 14:52:54 +01:00
shuuri-labs
333e045099 Lower socket auto-discovery log from Info to Debug (#5463)
The discovery message was printing on every CLI invocation, which is
noisy for users on distros using the systemd template.
2026-02-26 17:51:38 +01:00
Zoltan Papp
c2c4d9d336 [client] Fix Server mutex held across waitForUp in Up() (#5460)
Up() acquired s.mutex with a deferred unlock, then called waitForUp()
while still holding the lock. waitForUp() blocks for up to 50 seconds
waiting on clientRunningChan/clientGiveUpChan, starving all concurrent
gRPC calls that require the same mutex (Status, ListProfiles, etc.).

Replace the deferred unlock with explicit s.mutex.Unlock() on every
early-return path and immediately before waitForUp(), matching the
pattern already used by the clientRunning==true branch.
2026-02-26 16:47:02 +01:00
259 changed files with 8714 additions and 27606 deletions

14
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
blank_issues_enabled: true
contact_links:
- name: Community Support
url: https://forum.netbird.io/
about: Community support forum
- name: Cloud Support
url: https://docs.netbird.io/help/report-bug-issues
about: Contact us for support
- name: Client/Connection Troubleshooting
url: https://docs.netbird.io/help/troubleshooting-client
about: See our client troubleshooting guide for help addressing common issues
- name: Self-host Troubleshooting
url: https://docs.netbird.io/selfhosted/troubleshooting
about: See our self-host troubleshooting guide for help addressing common issues

View File

@@ -19,7 +19,7 @@ jobs:
- name: codespell
uses: codespell-project/actions-codespell@v2
with:
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te
skip: go.mod,go.sum,**/proxy/web/**
golangci:
strategy:

51
.github/workflows/pr-title-check.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: PR Title Check
on:
pull_request:
types: [opened, edited, synchronize, reopened]
jobs:
check-title:
runs-on: ubuntu-latest
steps:
- name: Validate PR title prefix
uses: actions/github-script@v7
with:
script: |
const title = context.payload.pull_request.title;
const allowedTags = [
'management',
'client',
'signal',
'proxy',
'relay',
'misc',
'infrastructure',
'self-hosted',
'doc',
];
const pattern = /^\[([^\]]+)\]\s+.+/;
const match = title.match(pattern);
if (!match) {
core.setFailed(
`PR title must start with a tag in brackets.\n` +
`Example: [client] fix something\n` +
`Allowed tags: ${allowedTags.join(', ')}`
);
return;
}
const tags = match[1].split(',').map(t => t.trim().toLowerCase());
const invalid = tags.filter(t => !allowedTags.includes(t));
if (invalid.length > 0) {
core.setFailed(
`Invalid tag(s): ${invalid.join(', ')}\n` +
`Allowed tags: ${allowedTags.join(', ')}`
);
return;
}
console.log(`Valid PR title tags: [${tags.join(', ')}]`);

View File

@@ -4,7 +4,7 @@
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
FROM alpine:3.23.2
FROM alpine:3.23.3
# iproute2: busybox doesn't display ip rules properly
RUN apk add --no-cache \
bash \

View File

@@ -331,8 +331,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
state.Set(StatusConnected)
if runningChan != nil {
close(runningChan)
runningChan = nil
select {
case <-runningChan:
default:
close(runningChan)
}
}
<-engineCtx.Done()

View File

@@ -49,7 +49,7 @@ func ResolveUnixDaemonAddr(addr string) string {
switch len(found) {
case 1:
resolved := "unix://" + found[0]
log.Infof("Default daemon socket not found, using discovered socket: %s", resolved)
log.Debugf("Default daemon socket not found, using discovered socket: %s", resolved)
return resolved
case 0:
return addr

View File

@@ -198,7 +198,7 @@ func getConfigDirForUser(username string) (string, error) {
configDir := filepath.Join(DefaultConfigPathDir, username)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
if err := os.MkdirAll(configDir, 0600); err != nil {
if err := os.MkdirAll(configDir, 0700); err != nil {
return "", err
}
}
@@ -206,9 +206,15 @@ func getConfigDirForUser(username string) (string, error) {
return configDir, nil
}
func fileExists(path string) bool {
func fileExists(path string) (bool, error) {
_, err := os.Stat(path)
return !os.IsNotExist(err)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// createNewConfig creates a new config generating a new Wireguard key and saving to file
@@ -635,7 +641,11 @@ func isPreSharedKeyHidden(preSharedKey *string) bool {
// UpdateConfig update existing configuration according to input configuration and return with the configuration
func UpdateConfig(input ConfigInput) (*Config, error) {
if !fileExists(input.ConfigPath) {
configExists, err := fileExists(input.ConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if !configExists {
return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath)
}
@@ -644,7 +654,11 @@ func UpdateConfig(input ConfigInput) (*Config, error) {
// UpdateOrCreateConfig reads existing config or generates a new one
func UpdateOrCreateConfig(input ConfigInput) (*Config, error) {
if !fileExists(input.ConfigPath) {
configExists, err := fileExists(input.ConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if !configExists {
log.Infof("generating new config %s", input.ConfigPath)
cfg, err := createNewConfig(input)
if err != nil {
@@ -657,7 +671,7 @@ func UpdateOrCreateConfig(input ConfigInput) (*Config, error) {
if isPreSharedKeyHidden(input.PreSharedKey) {
input.PreSharedKey = nil
}
err := util.EnforcePermission(input.ConfigPath)
err = util.EnforcePermission(input.ConfigPath)
if err != nil {
log.Errorf("failed to enforce permission on config dir: %v", err)
}
@@ -784,7 +798,12 @@ func ReadConfig(configPath string) (*Config, error) {
// ReadConfig read config file and return with Config. If it is not exists create a new with default values
func readConfig(configPath string, createIfMissing bool) (*Config, error) {
if fileExists(configPath) {
configExists, err := fileExists(configPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if configExists {
err := util.EnforcePermission(configPath)
if err != nil {
log.Errorf("failed to enforce permission on config dir: %v", err)
@@ -831,7 +850,11 @@ func DirectWriteOutConfig(path string, config *Config) error {
// DirectUpdateOrCreateConfig is like UpdateOrCreateConfig but uses direct (non-atomic) writes.
// Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox).
func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) {
if !fileExists(input.ConfigPath) {
configExists, err := fileExists(input.ConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if !configExists {
log.Infof("generating new config %s", input.ConfigPath)
cfg, err := createNewConfig(input)
if err != nil {

View File

@@ -256,7 +256,11 @@ func (s *ServiceManager) AddProfile(profileName, username string) error {
}
profPath := filepath.Join(configDir, profileName+".json")
if fileExists(profPath) {
profileExists, err := fileExists(profPath)
if err != nil {
return fmt.Errorf("failed to check if profile exists: %w", err)
}
if profileExists {
return ErrProfileAlreadyExists
}
@@ -285,7 +289,11 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error {
return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName)
}
profPath := filepath.Join(configDir, profileName+".json")
if !fileExists(profPath) {
profileExists, err := fileExists(profPath)
if err != nil {
return fmt.Errorf("failed to check if profile exists: %w", err)
}
if !profileExists {
return ErrProfileNotFound
}

View File

@@ -20,7 +20,11 @@ func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, er
}
stateFile := filepath.Join(configDir, profileName+".state.json")
if !fileExists(stateFile) {
stateFileExists, err := fileExists(stateFile)
if err != nil {
return nil, fmt.Errorf("failed to check if profile state file exists: %w", err)
}
if !stateFileExists {
return nil, errors.New("profile state file does not exist")
}

View File

@@ -263,8 +263,14 @@ func (w *Watcher) watchPeerStatusChanges(ctx context.Context, peerKey string, pe
case <-closer:
return
case routerStates := <-subscription.Events():
peerStateUpdate <- routerStates
log.Debugf("triggered route state update for Peer: %s", peerKey)
select {
case peerStateUpdate <- routerStates:
log.Debugf("triggered route state update for Peer: %s", peerKey)
case <-ctx.Done():
return
case <-closer:
return
}
}
}
}

View File

@@ -641,8 +641,6 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
return s.waitForUp(callerCtx)
}
defer s.mutex.Unlock()
if err := restoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil {
log.Warnf(errRestoreResidualState, err)
}
@@ -654,10 +652,12 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
// not in the progress or already successfully established connection.
status, err := state.Status()
if err != nil {
s.mutex.Unlock()
return nil, err
}
if status != internal.StatusIdle {
s.mutex.Unlock()
return nil, fmt.Errorf("up already in progress: current status %s", status)
}
@@ -674,17 +674,20 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
s.actCancel = cancel
if s.config == nil {
s.mutex.Unlock()
return nil, fmt.Errorf("config is not defined, please call login command first")
}
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
s.mutex.Unlock()
log.Errorf("failed to get active profile state: %v", err)
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
if msg != nil && msg.ProfileName != nil {
if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
s.mutex.Unlock()
log.Errorf("failed to switch profile: %v", err)
return nil, fmt.Errorf("failed to switch profile: %w", err)
}
@@ -692,6 +695,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
activeProf, err = s.profileManager.GetActiveProfileState()
if err != nil {
s.mutex.Unlock()
log.Errorf("failed to get active profile state: %v", err)
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
@@ -700,6 +704,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
config, _, err := s.getConfig(activeProf)
if err != nil {
s.mutex.Unlock()
log.Errorf("failed to get active profile config: %v", err)
return nil, fmt.Errorf("failed to get active profile config: %w", err)
}
@@ -718,6 +723,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
}
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, doAutoUpdate, s.clientRunningChan, s.clientGiveUpChan)
s.mutex.Unlock()
return s.waitForUp(callerCtx)
}
@@ -843,14 +849,26 @@ func (s *Server) cleanupConnection() error {
if s.actCancel == nil {
return ErrServiceNotUp
}
// Capture the engine reference before cancelling the context.
// After actCancel(), the connectWithRetryRuns goroutine wakes up
// and sets connectClient.engine = nil, causing connectClient.Stop()
// to skip the engine shutdown entirely.
var engine *internal.Engine
if s.connectClient != nil {
engine = s.connectClient.Engine()
}
s.actCancel()
if s.connectClient == nil {
return nil
}
if err := s.connectClient.Stop(); err != nil {
return err
if engine != nil {
if err := engine.Stop(); err != nil {
return err
}
}
s.connectClient = nil

View File

@@ -46,8 +46,10 @@ const (
cmdSFTP = "<sftp>"
cmdNonInteractive = "<idle>"
// DefaultJWTMaxTokenAge is the default maximum age for JWT tokens accepted by the SSH server
DefaultJWTMaxTokenAge = 5 * 60
// DefaultJWTMaxTokenAge is the default maximum age for JWT tokens accepted by the SSH server.
// Set to 10 minutes to accommodate identity providers like Azure Entra ID
// that backdate the iat claim by up to 5 minutes.
DefaultJWTMaxTokenAge = 10 * 60
)
var (

View File

@@ -1,3 +0,0 @@
frontend/node_modules/
frontend/dist/
src-tauri/target/

View File

@@ -1,26 +0,0 @@
# NetBird Tauri UI
## Prerequisites
- Rust (https://rustup.rs)
- Node.js 18+
- Linux: `sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf protobuf-compiler`
## Build & Run
```bash
# Frontend
cd frontend && npm install && npm run build && cd ..
# Backend (debug)
cd src-tauri && cargo build
# Run
RUST_LOG=info ./src-tauri/target/debug/netbird-ui
# Release build
cd src-tauri && cargo build --release
./src-tauri/target/release/netbird-ui
```
The NetBird daemon must be running (`netbird service start`).

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>NetBird</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
{
"name": "netbird-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.6",
"typescript": "^5.6.3",
"vite": "^6.0.5"
}
}

View File

@@ -1,59 +0,0 @@
import { HashRouter, Routes, Route, useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
import { listen } from '@tauri-apps/api/event'
import Status from './pages/Status'
import Settings from './pages/Settings'
import Networks from './pages/Networks'
import Profiles from './pages/Profiles'
import Peers from './pages/Peers'
import Debug from './pages/Debug'
import Update from './pages/Update'
import NavBar from './components/NavBar'
/**
* Navigator listens for the "navigate" event emitted by the Rust backend
* and programmatically navigates the React router.
*/
function Navigator() {
const navigate = useNavigate()
useEffect(() => {
const unlisten = listen<string>('navigate', (event) => {
const path = event.payload
if (path) navigate(path)
})
return () => {
unlisten.then(fn => fn())
}
}, [navigate])
return null
}
export default function App() {
return (
<HashRouter>
<Navigator />
<div
className="min-h-screen flex"
style={{
backgroundColor: 'var(--color-bg-primary)',
color: 'var(--color-text-primary)',
}}
>
<NavBar />
<main className="flex-1 px-10 py-8 overflow-y-auto h-screen">
<Routes>
<Route path="/" element={<Status />} />
<Route path="/settings" element={<Settings />} />
<Route path="/peers" element={<Peers />} />
<Route path="/networks" element={<Networks />} />
<Route path="/profiles" element={<Profiles />} />
<Route path="/debug" element={<Debug />} />
<Route path="/update" element={<Update />} />
</Routes>
</main>
</div>
</HashRouter>
)
}

View File

@@ -1,100 +0,0 @@
/**
* Type definitions for Tauri command responses.
* These mirror the Rust serde-serialized DTOs.
*/
// ---- Connection service ----
export interface StatusInfo {
status: string
ip: string
publicKey: string
fqdn: string
connectedPeers: number
}
// ---- Settings service ----
export interface ConfigInfo {
managementUrl: string
adminUrl: string
preSharedKey: string
interfaceName: string
wireguardPort: number
disableAutoConnect: boolean
serverSshAllowed: boolean
rosenpassEnabled: boolean
rosenpassPermissive: boolean
lazyConnectionEnabled: boolean
blockInbound: boolean
disableNotifications: boolean
}
// ---- Network service ----
export interface NetworkInfo {
id: string
range: string
domains: string[]
selected: boolean
resolvedIPs: Record<string, string[]>
}
// ---- Profile service ----
export interface ProfileInfo {
name: string
isActive: boolean
}
export interface ActiveProfileInfo {
profileName: string
username: string
email: string
}
// ---- Debug service ----
export interface DebugBundleParams {
anonymize: boolean
systemInfo: boolean
upload: boolean
uploadUrl: string
runDurationMins: number
enablePersistence: boolean
}
export interface DebugBundleResult {
localPath: string
uploadedKey: string
uploadFailureReason: string
}
// ---- Peers service ----
export interface PeerInfo {
ip: string
pubKey: string
fqdn: string
connStatus: string
connStatusUpdate: string
relayed: boolean
relayAddress: string
latencyMs: number
bytesRx: number
bytesTx: number
rosenpassEnabled: boolean
networks: string[]
lastHandshake: string
localIceType: string
remoteIceType: string
localEndpoint: string
remoteEndpoint: string
}
// ---- Update service ----
export interface InstallerResult {
success: boolean
errorMsg: string
}

View File

@@ -1,162 +0,0 @@
import { NavLink } from 'react-router-dom'
import NetBirdLogo from './NetBirdLogo'
const mainItems = [
{ to: '/', label: 'Status', icon: StatusIcon },
{ to: '/peers', label: 'Peers', icon: PeersIcon },
{ to: '/networks', label: 'Networks', icon: NetworksIcon },
{ to: '/profiles', label: 'Profiles', icon: ProfilesIcon },
]
const systemItems = [
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
{ to: '/debug', label: 'Debug', icon: DebugIcon },
{ to: '/update', label: 'Update', icon: UpdateIcon },
]
function NavGroup({ items }: { items: typeof mainItems }) {
return (
<div className="space-y-0.5">
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className="block"
>
{({ isActive }) => (
<div
className="flex items-center gap-2.5 px-2.5 py-[5px] rounded-[var(--radius-sidebar-item)] text-[13px] transition-colors"
style={{
backgroundColor: isActive ? 'var(--color-sidebar-selected)' : 'transparent',
fontWeight: isActive ? 500 : 400,
color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.backgroundColor = 'var(--color-sidebar-hover)'
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<item.icon active={isActive} />
<span>{item.label}</span>
</div>
)}
</NavLink>
))}
</div>
)
}
export default function NavBar() {
return (
<nav
className="w-[216px] min-w-[216px] flex flex-col h-screen"
style={{
backgroundColor: 'var(--color-bg-sidebar)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderRight: '0.5px solid var(--color-separator)',
}}
>
{/* Logo */}
<div className="px-4 py-4" style={{ borderBottom: '0.5px solid var(--color-separator)' }}>
<NetBirdLogo full />
</div>
{/* Nav items */}
<div className="flex-1 px-2.5 py-3 overflow-y-auto">
<NavGroup items={mainItems} />
<div className="my-2 mx-2.5" style={{ borderTop: '0.5px solid var(--color-separator)' }} />
<NavGroup items={systemItems} />
</div>
{/* Version footer */}
<div className="px-4 py-2.5 text-[11px]" style={{ color: 'var(--color-text-quaternary)', borderTop: '0.5px solid var(--color-separator)' }}>
NetBird Client
</div>
</nav>
)
}
/* ── Icons (18px, stroke) ──────────────────────────────────────── */
function StatusIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
)
}
function PeersIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
}
function NetworksIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="2" />
<circle cx="5" cy="19" r="2" />
<circle cx="19" cy="19" r="2" />
<line x1="12" y1="7" x2="5" y2="17" />
<line x1="12" y1="7" x2="19" y2="17" />
</svg>
)
}
function ProfilesIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
}
function SettingsIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
function DebugIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m8 2 1.88 1.88" />
<path d="M14.12 3.88 16 2" />
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
<path d="M12 20v-9" />
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
<path d="M6 13H2" />
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
<path d="M22 13h-4" />
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
</svg>
)
}
function UpdateIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
)
}

View File

@@ -1,20 +0,0 @@
function BirdMark({ className }: { className?: string }) {
return (
<svg className={className} 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>
)
}
export default function NetBirdLogo({ full = false, className }: { full?: boolean; className?: string }) {
if (!full) return <BirdMark className={className} />
return (
<div className={`flex items-center gap-2 ${className ?? ''}`}>
<BirdMark />
<span className="text-lg font-bold tracking-wide" style={{ color: 'var(--color-text-primary)' }}>NETBIRD</span>
</div>
)
}

View File

@@ -1,35 +0,0 @@
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'destructive'
size?: 'sm' | 'md'
}
const styles: Record<string, React.CSSProperties> = {
primary: {
backgroundColor: 'var(--color-accent)',
color: '#ffffff',
},
secondary: {
backgroundColor: 'var(--color-control-bg)',
color: 'var(--color-text-primary)',
},
destructive: {
backgroundColor: 'var(--color-status-red-bg)',
color: 'var(--color-status-red)',
},
}
export default function Button({ variant = 'primary', size = 'md', className, style, children, ...props }: ButtonProps) {
const variantStyle = styles[variant]
const pad = size === 'sm' ? '4px 12px' : '6px 20px'
const fontSize = size === 'sm' ? 12 : 13
return (
<button
className={`inline-flex items-center justify-center gap-1.5 font-medium rounded-[8px] transition-opacity hover:opacity-85 active:opacity-75 disabled:opacity-50 disabled:cursor-not-allowed ${className ?? ''}`}
style={{ padding: pad, fontSize, ...variantStyle, ...style }}
{...props}
>
{children}
</button>
)
}

View File

@@ -1,26 +0,0 @@
interface CardProps {
label?: string
children: React.ReactNode
className?: string
}
export default function Card({ label, children, className }: CardProps) {
return (
<div className={className}>
{label && (
<h3 className="text-[11px] font-semibold uppercase tracking-wide px-4 mb-1.5" style={{ color: 'var(--color-text-tertiary)' }}>
{label}
</h3>
)}
<div
className="rounded-[var(--radius-card)] overflow-hidden"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
}}
>
{children}
</div>
</div>
)
}

View File

@@ -1,31 +0,0 @@
interface CardRowProps {
label?: string
description?: string
children?: React.ReactNode
className?: string
onClick?: () => void
}
export default function CardRow({ label, description, children, className, onClick }: CardRowProps) {
return (
<div
className={`flex items-center justify-between gap-4 px-4 py-3 min-h-[44px] ${onClick ? 'cursor-pointer' : ''} ${className ?? ''}`}
style={{ borderBottom: '0.5px solid var(--color-separator)' }}
onClick={onClick}
>
<div className="flex flex-col min-w-0 flex-1">
{label && (
<span className="text-[13px]" style={{ color: 'var(--color-text-primary)' }}>
{label}
</span>
)}
{description && (
<span className="text-[11px] mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>
{description}
</span>
)}
</div>
{children && <div className="shrink-0 flex items-center">{children}</div>}
</div>
)
}

View File

@@ -1,40 +0,0 @@
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
}
export default function Input({ label, className, style, ...props }: InputProps) {
const input = (
<input
className={`w-full rounded-[var(--radius-control)] text-[13px] outline-none transition-shadow ${className ?? ''}`}
style={{
height: 28,
padding: '0 8px',
backgroundColor: 'var(--color-input-bg)',
border: '0.5px solid var(--color-input-border)',
color: 'var(--color-text-primary)',
boxShadow: 'none',
...style,
}}
onFocus={e => {
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(246,131,48,0.3)'
e.currentTarget.style.borderColor = 'var(--color-accent)'
}}
onBlur={e => {
e.currentTarget.style.boxShadow = 'none'
e.currentTarget.style.borderColor = 'var(--color-input-border)'
}}
{...props}
/>
)
if (!label) return input
return (
<div>
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
{label}
</label>
{input}
</div>
)
}

View File

@@ -1,46 +0,0 @@
import Button from './Button'
interface ModalProps {
title: string
message: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
loading?: boolean
onConfirm: () => void
onCancel: () => void
}
export default function Modal({ title, message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', destructive, loading, onConfirm, onCancel }: ModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}>
<div
className="max-w-sm w-full mx-4 p-5 rounded-[12px]"
style={{
backgroundColor: 'var(--color-bg-elevated)',
boxShadow: 'var(--shadow-elevated)',
}}
>
<h2 className="text-[15px] font-semibold mb-1" style={{ color: 'var(--color-text-primary)' }}>
{title}
</h2>
<p className="text-[13px] mb-5" style={{ color: 'var(--color-text-secondary)' }}>
{message}
</p>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={onCancel}>
{cancelLabel}
</Button>
<Button
variant={destructive ? 'destructive' : 'primary'}
size="sm"
onClick={onConfirm}
disabled={loading}
>
{confirmLabel}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,47 +0,0 @@
interface SearchInputProps {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
}
export default function SearchInput({ value, onChange, placeholder = 'Search...', className }: SearchInputProps) {
return (
<div className={`relative ${className ?? ''}`}>
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5"
style={{ color: 'var(--color-text-tertiary)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx={11} cy={11} r={8} />
<path d="m21 21-4.3-4.3" />
</svg>
<input
className="w-full text-[13px] outline-none transition-shadow"
style={{
height: 28,
paddingLeft: 28,
paddingRight: 8,
backgroundColor: 'var(--color-control-bg)',
border: '0.5px solid transparent',
borderRadius: 999,
color: 'var(--color-text-primary)',
}}
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
onFocus={e => {
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(246,131,48,0.3)'
e.currentTarget.style.borderColor = 'var(--color-accent)'
}}
onBlur={e => {
e.currentTarget.style.boxShadow = 'none'
e.currentTarget.style.borderColor = 'transparent'
}}
/>
</div>
)
}

View File

@@ -1,34 +0,0 @@
interface SegmentedControlProps<T extends string> {
options: { value: T; label: string }[]
value: T
onChange: (value: T) => void
className?: string
}
export default function SegmentedControl<T extends string>({ options, value, onChange, className }: SegmentedControlProps<T>) {
return (
<div
className={`inline-flex rounded-[8px] p-[3px] ${className ?? ''}`}
style={{ backgroundColor: 'var(--color-control-bg)' }}
>
{options.map(opt => {
const active = opt.value === value
return (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
className="relative px-3 py-1 text-[12px] font-medium rounded-[6px] transition-all duration-200"
style={{
backgroundColor: active ? 'var(--color-bg-elevated)' : 'transparent',
color: active ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
boxShadow: active ? 'var(--shadow-segment)' : 'none',
minWidth: 64,
}}
>
{opt.label}
</button>
)
})}
</div>
)
}

View File

@@ -1,34 +0,0 @@
interface StatusBadgeProps {
status: 'connected' | 'disconnected' | 'connecting' | string
label?: string
}
function getStatusColors(status: string): { dot: string; text: string; bg: string } {
switch (status.toLowerCase()) {
case 'connected':
return { dot: 'var(--color-status-green)', text: 'var(--color-status-green)', bg: 'var(--color-status-green-bg)' }
case 'connecting':
return { dot: 'var(--color-status-yellow)', text: 'var(--color-status-yellow)', bg: 'var(--color-status-yellow-bg)' }
case 'disconnected':
return { dot: 'var(--color-status-gray)', text: 'var(--color-text-secondary)', bg: 'var(--color-status-gray-bg)' }
default:
return { dot: 'var(--color-status-red)', text: 'var(--color-status-red)', bg: 'var(--color-status-red-bg)' }
}
}
export default function StatusBadge({ status, label }: StatusBadgeProps) {
const colors = getStatusColors(status)
return (
<span
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium"
style={{ backgroundColor: colors.bg, color: colors.text }}
>
<span
className={`w-1.5 h-1.5 rounded-full ${status.toLowerCase() === 'connecting' ? 'animate-pulse' : ''}`}
style={{ backgroundColor: colors.dot }}
/>
{label ?? status}
</span>
)
}

View File

@@ -1,72 +0,0 @@
/* Table primitives for macOS System Settings style tables */
export function TableContainer({ children }: { children: React.ReactNode }) {
return (
<div
className="rounded-[var(--radius-card)] overflow-hidden"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
}}
>
{children}
</div>
)
}
export function TableHeader({ children }: { children: React.ReactNode }) {
return (
<thead>
<tr style={{ borderBottom: '0.5px solid var(--color-separator)' }}>
{children}
</tr>
</thead>
)
}
export function TableHeaderCell({ children, onClick, className }: { children: React.ReactNode; onClick?: () => void; className?: string }) {
return (
<th
className={`px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wide ${onClick ? 'cursor-pointer select-none' : ''} ${className ?? ''}`}
style={{ color: 'var(--color-text-tertiary)' }}
onClick={onClick}
>
{children}
</th>
)
}
export function TableRow({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<tr
className={`transition-colors group/row ${className ?? ''}`}
style={{ borderBottom: '0.5px solid var(--color-separator)' }}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'var(--color-sidebar-hover)')}
onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'transparent')}
>
{children}
</tr>
)
}
export function TableCell({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<td className={`px-4 py-3 align-middle ${className ?? ''}`}>
{children}
</td>
)
}
export function TableFooter({ children }: { children: React.ReactNode }) {
return (
<div
className="px-4 py-2 text-[11px]"
style={{
borderTop: '0.5px solid var(--color-separator)',
color: 'var(--color-text-tertiary)',
}}
>
{children}
</div>
)
}

View File

@@ -1,39 +0,0 @@
interface ToggleProps {
checked: boolean
onChange: (value: boolean) => void
small?: boolean
disabled?: boolean
}
export default function Toggle({ checked, onChange, small, disabled }: ToggleProps) {
const w = small ? 30 : 38
const h = small ? 18 : 22
const thumb = small ? 14 : 18
const travel = w - thumb - 4
return (
<button
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className="relative inline-flex shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-50"
style={{
width: w,
height: h,
backgroundColor: checked ? 'var(--color-accent)' : 'var(--color-control-bg)',
padding: 2,
}}
>
<span
className="block rounded-full bg-white transition-transform duration-200"
style={{
width: thumb,
height: thumb,
transform: `translateX(${checked ? travel : 0}px)`,
boxShadow: '0 1px 3px rgba(0,0,0,0.15), 0 0.5px 1px rgba(0,0,0,0.1)',
}}
/>
</button>
)
}

View File

@@ -1,186 +0,0 @@
@import "tailwindcss";
/* ── Light-mode tokens (default) ────────────────────────────────── */
:root {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f5f5f7;
--color-bg-tertiary: #e8e8ed;
--color-bg-elevated: #ffffff;
--color-bg-sidebar: rgba(245, 245, 247, 0.8);
--color-sidebar-selected: rgba(0, 0, 0, 0.06);
--color-sidebar-hover: rgba(0, 0, 0, 0.04);
--color-text-primary: #1d1d1f;
--color-text-secondary: #6e6e73;
--color-text-tertiary: #86868b;
--color-text-quaternary: #aeaeb2;
--color-separator: rgba(0, 0, 0, 0.09);
--color-separator-heavy: rgba(0, 0, 0, 0.16);
--color-accent: #f68330;
--color-accent-hover: #e55311;
--color-status-green: #34c759;
--color-status-green-bg: rgba(52, 199, 89, 0.12);
--color-status-yellow: #ff9f0a;
--color-status-yellow-bg: rgba(255, 159, 10, 0.12);
--color-status-red: #ff3b30;
--color-status-red-bg: rgba(255, 59, 48, 0.12);
--color-status-gray: #8e8e93;
--color-status-gray-bg: rgba(142, 142, 147, 0.12);
--color-input-bg: #ffffff;
--color-input-border: rgba(0, 0, 0, 0.12);
--color-input-focus: var(--color-accent);
--color-control-bg: rgba(116, 116, 128, 0.08);
--shadow-card: 0 0.5px 1px rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.08);
--shadow-elevated: 0 2px 8px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);
--shadow-segment: 0 1px 3px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.06);
--radius-card: 10px;
--radius-control: 6px;
--radius-sidebar-item: 7px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
color-scheme: light dark;
}
/* ── Dark-mode tokens ───────────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
:root {
--color-bg-primary: #1c1c1e;
--color-bg-secondary: #2c2c2e;
--color-bg-tertiary: #3a3a3c;
--color-bg-elevated: #2c2c2e;
--color-bg-sidebar: rgba(44, 44, 46, 0.8);
--color-sidebar-selected: rgba(255, 255, 255, 0.08);
--color-sidebar-hover: rgba(255, 255, 255, 0.05);
--color-text-primary: #f5f5f7;
--color-text-secondary: #98989d;
--color-text-tertiary: #6e6e73;
--color-text-quaternary: #48484a;
--color-separator: rgba(255, 255, 255, 0.08);
--color-separator-heavy: rgba(255, 255, 255, 0.15);
--color-status-green: #30d158;
--color-status-green-bg: rgba(48, 209, 88, 0.15);
--color-status-yellow: #ffd60a;
--color-status-yellow-bg: rgba(255, 214, 10, 0.15);
--color-status-red: #ff453a;
--color-status-red-bg: rgba(255, 69, 58, 0.15);
--color-status-gray: #636366;
--color-status-gray-bg: rgba(99, 99, 102, 0.15);
--color-input-bg: rgba(255, 255, 255, 0.05);
--color-input-border: rgba(255, 255, 255, 0.1);
--color-control-bg: rgba(118, 118, 128, 0.24);
--shadow-card: 0 0.5px 1px rgba(0,0,0,0.2), 0 1px 3px rgba(0,0,0,0.3);
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.4), 0 1px 4px rgba(0,0,0,0.3);
--shadow-segment: 0 1px 3px rgba(0,0,0,0.3), 0 0.5px 1px rgba(0,0,0,0.2);
}
}
/* Manual toggle for WebKitGTK fallback */
[data-theme="dark"] {
--color-bg-primary: #1c1c1e;
--color-bg-secondary: #2c2c2e;
--color-bg-tertiary: #3a3a3c;
--color-bg-elevated: #2c2c2e;
--color-bg-sidebar: rgba(44, 44, 46, 0.8);
--color-sidebar-selected: rgba(255, 255, 255, 0.08);
--color-sidebar-hover: rgba(255, 255, 255, 0.05);
--color-text-primary: #f5f5f7;
--color-text-secondary: #98989d;
--color-text-tertiary: #6e6e73;
--color-text-quaternary: #48484a;
--color-separator: rgba(255, 255, 255, 0.08);
--color-separator-heavy: rgba(255, 255, 255, 0.15);
--color-status-green: #30d158;
--color-status-green-bg: rgba(48, 209, 88, 0.15);
--color-status-yellow: #ffd60a;
--color-status-yellow-bg: rgba(255, 214, 10, 0.15);
--color-status-red: #ff453a;
--color-status-red-bg: rgba(255, 69, 58, 0.15);
--color-status-gray: #636366;
--color-status-gray-bg: rgba(99, 99, 102, 0.15);
--color-input-bg: rgba(255, 255, 255, 0.05);
--color-input-border: rgba(255, 255, 255, 0.1);
--color-control-bg: rgba(118, 118, 128, 0.24);
--shadow-card: 0 0.5px 1px rgba(0,0,0,0.2), 0 1px 3px rgba(0,0,0,0.3);
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.4), 0 1px 4px rgba(0,0,0,0.3);
--shadow-segment: 0 1px 3px rgba(0,0,0,0.3), 0 0.5px 1px rgba(0,0,0,0.2);
}
@theme {
--color-netbird-50: #fff6ed;
--color-netbird-100: #feecd6;
--color-netbird-150: #ffdfb8;
--color-netbird-200: #ffd4a6;
--color-netbird-300: #fab677;
--color-netbird-400: #f68330;
--color-netbird-DEFAULT: #f68330;
--color-netbird-500: #f46d1b;
--color-netbird-600: #e55311;
--color-netbird-700: #be3e10;
--color-netbird-800: #973215;
--color-netbird-900: #7a2b14;
--color-netbird-950: #421308;
--font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
}
/* ── Base ────────────────────────────────────────────────────────── */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: 13px;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
/* ── Scrollbar (macOS-like thin) ────────────────────────────────── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-text-quaternary);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
background-clip: content-box;
}

View File

@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -1,180 +0,0 @@
import { useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { DebugBundleParams, DebugBundleResult } from '../bindings'
import Card from '../components/ui/Card'
import CardRow from '../components/ui/CardRow'
import Toggle from '../components/ui/Toggle'
import Input from '../components/ui/Input'
import Button from '../components/ui/Button'
const DEFAULT_UPLOAD_URL = 'https://upload.netbird.io'
export default function Debug() {
const [anonymize, setAnonymize] = useState(false)
const [systemInfo, setSystemInfo] = useState(true)
const [upload, setUpload] = useState(true)
const [uploadUrl, setUploadUrl] = useState(DEFAULT_UPLOAD_URL)
const [runForDuration, setRunForDuration] = useState(true)
const [durationMins, setDurationMins] = useState(1)
const [running, setRunning] = useState(false)
const [progress, setProgress] = useState('')
const [result, setResult] = useState<DebugBundleResult | null>(null)
const [error, setError] = useState<string | null>(null)
async function handleCreate() {
if (upload && !uploadUrl) {
setError('Upload URL is required when upload is enabled')
return
}
setRunning(true)
setError(null)
setResult(null)
setProgress(runForDuration ? `Running with trace logs for ${durationMins} minute(s)\u2026` : 'Creating debug bundle\u2026')
const params: DebugBundleParams = {
anonymize,
systemInfo,
upload,
uploadUrl: upload ? uploadUrl : '',
runDurationMins: runForDuration ? durationMins : 0,
enablePersistence: true,
}
try {
const res = await invoke<DebugBundleResult>('create_debug_bundle', { params })
if (res) {
setResult(res)
setProgress('Bundle created successfully')
}
} catch (e) {
console.error('[Debug] CreateDebugBundle error:', e)
setError(String(e))
setProgress('')
} finally {
setRunning(false)
}
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-semibold mb-1" style={{ color: 'var(--color-text-primary)' }}>Debug</h1>
<p className="text-[13px] mb-6" style={{ color: 'var(--color-text-secondary)' }}>
Create a debug bundle to help troubleshoot issues with NetBird.
</p>
<Card label="OPTIONS" className="mb-5">
<CardRow label="Anonymize sensitive information">
<Toggle checked={anonymize} onChange={setAnonymize} />
</CardRow>
<CardRow label="Include system information">
<Toggle checked={systemInfo} onChange={setSystemInfo} />
</CardRow>
<CardRow label="Upload bundle automatically">
<Toggle checked={upload} onChange={setUpload} />
</CardRow>
</Card>
{upload && (
<Card label="UPLOAD" className="mb-5">
<CardRow label="Upload URL">
<Input
value={uploadUrl}
onChange={e => setUploadUrl(e.target.value)}
disabled={running}
style={{ width: 240 }}
/>
</CardRow>
</Card>
)}
<Card label="TRACE LOGGING" className="mb-5">
<CardRow label="Run with trace logs before creating bundle">
<Toggle checked={runForDuration} onChange={setRunForDuration} />
</CardRow>
{runForDuration && (
<CardRow label="Duration">
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={60}
value={durationMins}
onChange={e => setDurationMins(Math.max(1, parseInt(e.target.value) || 1))}
disabled={running}
style={{ width: 64, textAlign: 'center' }}
/>
<span className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>
{durationMins === 1 ? 'minute' : 'minutes'}
</span>
</div>
</CardRow>
)}
{runForDuration && (
<div className="px-4 py-2 text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
Note: NetBird will be brought up and down during collection.
</div>
)}
</Card>
{error && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{ backgroundColor: 'var(--color-status-red-bg)', color: 'var(--color-status-red)' }}
>
{error}
</div>
)}
{progress && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
color: running ? 'var(--color-status-yellow)' : 'var(--color-status-green)',
}}
>
<span className={running ? 'animate-pulse' : ''}>{progress}</span>
</div>
)}
{result && (
<Card className="mb-4">
<div className="px-4 py-3 space-y-2 text-[13px]">
{result.uploadedKey ? (
<>
<p style={{ color: 'var(--color-status-green)' }} className="font-medium">Bundle uploaded successfully!</p>
<div className="flex items-center gap-2">
<span style={{ color: 'var(--color-text-secondary)' }}>Upload key:</span>
<code
className="px-2 py-0.5 rounded text-[12px] font-mono"
style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
>
{result.uploadedKey}
</code>
</div>
</>
) : result.uploadFailureReason ? (
<p style={{ color: 'var(--color-status-yellow)' }}>Upload failed: {result.uploadFailureReason}</p>
) : null}
<div className="flex items-center gap-2">
<span style={{ color: 'var(--color-text-secondary)' }}>Local path:</span>
<code
className="px-2 py-0.5 rounded text-[12px] font-mono break-all"
style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
>
{result.localPath}
</code>
</div>
</div>
</Card>
)}
<Button onClick={handleCreate} disabled={running}>
{running ? 'Running\u2026' : 'Create Debug Bundle'}
</Button>
</div>
)
}

View File

@@ -1,335 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { NetworkInfo } from '../bindings'
import SearchInput from '../components/ui/SearchInput'
import Button from '../components/ui/Button'
import Toggle from '../components/ui/Toggle'
import SegmentedControl from '../components/ui/SegmentedControl'
import { TableContainer, TableHeader, TableHeaderCell, TableRow, TableCell, TableFooter } from '../components/ui/Table'
type Tab = 'all' | 'overlapping' | 'exit-node'
type SortKey = 'id' | 'range'
type SortDir = 'asc' | 'desc'
const tabOptions: { value: Tab; label: string }[] = [
{ value: 'all', label: 'All Networks' },
{ value: 'overlapping', label: 'Overlapping' },
{ value: 'exit-node', label: 'Exit Nodes' },
]
export default function Networks() {
const [networks, setNetworks] = useState<NetworkInfo[]>([])
const [tab, setTab] = useState<Tab>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey>('id')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
let command: string
if (tab === 'all') command = 'list_networks'
else if (tab === 'overlapping') command = 'list_overlapping_networks'
else command = 'list_exit_nodes'
const data = await invoke<NetworkInfo[]>(command)
setNetworks(data ?? [])
} catch (e) {
console.error('[Networks] load error:', e)
setError(String(e))
} finally {
setLoading(false)
}
}, [tab])
useEffect(() => {
load()
const id = setInterval(load, 10000)
return () => clearInterval(id)
}, [load])
const filtered = useMemo(() => {
let list = networks
if (search) {
const q = search.toLowerCase()
list = list.filter(n =>
n.id.toLowerCase().includes(q) ||
n.range?.toLowerCase().includes(q) ||
n.domains?.some(d => d.toLowerCase().includes(q))
)
}
return [...list].sort((a, b) => {
const aVal = sortKey === 'id' ? a.id : (a.range ?? '')
const bVal = sortKey === 'id' ? b.id : (b.range ?? '')
const cmp = aVal.localeCompare(bVal)
return sortDir === 'asc' ? cmp : -cmp
})
}, [networks, search, sortKey, sortDir])
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('asc')
}
}
async function toggle(id: string, selected: boolean) {
try {
if (selected) await invoke('deselect_network', { id })
else await invoke('select_network', { id })
await load()
} catch (e) {
setError(String(e))
}
}
async function selectAll() {
try {
await invoke('select_all_networks')
await load()
} catch (e) { setError(String(e)) }
}
async function deselectAll() {
try {
await invoke('deselect_all_networks')
await load()
} catch (e) { setError(String(e)) }
}
const selectedCount = networks.filter(n => n.selected).length
return (
<div className="max-w-5xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Networks</h1>
<SegmentedControl options={tabOptions} value={tab} onChange={setTab} className="mb-5" />
{/* Toolbar */}
<div className="flex items-center gap-3 mb-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by name, range or domain..."
className="flex-1 max-w-sm"
/>
<div className="flex gap-2 ml-auto">
<Button variant="secondary" size="sm" onClick={selectAll}>Select All</Button>
<Button variant="secondary" size="sm" onClick={deselectAll}>Deselect All</Button>
<Button variant="secondary" size="sm" onClick={load}>Refresh</Button>
</div>
</div>
{error && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[12px]"
style={{ backgroundColor: 'var(--color-status-red-bg)', color: 'var(--color-status-red)' }}
>
{error}
</div>
)}
{selectedCount > 0 && (
<div className="mb-3 text-[12px]" style={{ color: 'var(--color-text-tertiary)' }}>
{selectedCount} of {networks.length} network{networks.length !== 1 ? 's' : ''} selected
</div>
)}
{loading && networks.length === 0 ? (
<TableSkeleton />
) : filtered.length === 0 && networks.length === 0 ? (
<EmptyState tab={tab} />
) : filtered.length === 0 ? (
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>
No networks match your search.
<button onClick={() => setSearch('')} className="ml-2 hover:underline" style={{ color: 'var(--color-accent)' }}>Clear search</button>
</div>
) : (
<TableContainer>
<table className="w-full text-[13px]">
<TableHeader>
<SortableHeader label="Network" sortKey="id" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="Range / Domains" sortKey="range" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<TableHeaderCell>Resolved IPs</TableHeaderCell>
<TableHeaderCell className="w-20">Active</TableHeaderCell>
</TableHeader>
<tbody>
{filtered.map(n => (
<NetworkRow key={n.id} network={n} onToggle={() => toggle(n.id, n.selected)} />
))}
</tbody>
</table>
<TableFooter>
Showing {filtered.length} of {networks.length} network{networks.length !== 1 ? 's' : ''}
</TableFooter>
</TableContainer>
)}
</div>
)
}
/* ---- Row ---- */
function NetworkRow({ network, onToggle }: { network: NetworkInfo; onToggle: () => void }) {
const domains = network.domains ?? []
const resolvedEntries = Object.entries(network.resolvedIPs ?? {})
const hasDomains = domains.length > 0
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-3 min-w-[180px]">
<NetworkSquare name={network.id} active={network.selected} />
<div className="flex flex-col">
<span className="font-medium text-[13px]" style={{ color: 'var(--color-text-primary)' }}>{network.id}</span>
{hasDomains && domains.length > 1 && (
<span className="text-[11px] mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>{domains.length} domains</span>
)}
</div>
</div>
</TableCell>
<TableCell>
{hasDomains ? (
<div className="flex flex-col gap-1">
{domains.slice(0, 2).map(d => (
<span key={d} className="font-mono text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>{d}</span>
))}
{domains.length > 2 && (
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }} title={domains.join(', ')}>+{domains.length - 2} more</span>
)}
</div>
) : (
<span className="font-mono text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>{network.range}</span>
)}
</TableCell>
<TableCell>
{resolvedEntries.length > 0 ? (
<div className="flex flex-col gap-1">
{resolvedEntries.slice(0, 2).map(([domain, ips]) => (
<span key={domain} className="font-mono text-[11px]" style={{ color: 'var(--color-text-tertiary)' }} title={`${domain}: ${ips.join(', ')}`}>
{ips[0]}{ips.length > 1 && <span style={{ color: 'var(--color-text-quaternary)' }}> +{ips.length - 1}</span>}
</span>
))}
{resolvedEntries.length > 2 && (
<span className="text-[11px]" style={{ color: 'var(--color-text-quaternary)' }}>+{resolvedEntries.length - 2} more</span>
)}
</div>
) : (
<span style={{ color: 'var(--color-text-quaternary)' }}>{'\u2014'}</span>
)}
</TableCell>
<TableCell>
<Toggle checked={network.selected} onChange={onToggle} small />
</TableCell>
</TableRow>
)
}
/* ---- Network Icon Square ---- */
function NetworkSquare({ name, active }: { name: string; active: boolean }) {
const initials = name.substring(0, 2).toUpperCase()
return (
<div
className="relative h-10 w-10 shrink-0 rounded-[var(--radius-control)] flex items-center justify-center text-[13px] font-medium uppercase"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
color: 'var(--color-text-primary)',
}}
>
{initials}
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full"
style={{
backgroundColor: active ? 'var(--color-status-green)' : 'var(--color-status-gray)',
border: '2px solid var(--color-bg-secondary)',
}}
/>
</div>
)
}
/* ---- Sortable Header ---- */
function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
label: string; sortKey: SortKey; currentKey: SortKey; dir: SortDir; onSort: (k: SortKey) => void
}) {
const isActive = currentKey === sortKey
return (
<TableHeaderCell onClick={() => onSort(sortKey)}>
<span className="inline-flex items-center gap-1">
{label}
{isActive && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{dir === 'asc' ? <path d="M5 15l7-7 7 7" /> : <path d="M19 9l-7 7-7-7" />}
</svg>
)}
</span>
</TableHeaderCell>
)
}
/* ---- Empty State ---- */
function EmptyState({ tab }: { tab: Tab }) {
const msg = tab === 'exit-node'
? 'No exit nodes configured.'
: tab === 'overlapping'
? 'No overlapping networks detected.'
: 'No networks found.'
return (
<div
className="rounded-[var(--radius-card)] py-16 flex flex-col items-center gap-3"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
}}
>
<div
className="h-12 w-12 rounded-[var(--radius-card)] flex items-center justify-center"
style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
>
<svg className="w-6 h-6" style={{ color: 'var(--color-text-tertiary)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A8.966 8.966 0 013 12c0-1.777.514-3.434 1.4-4.832" />
</svg>
</div>
<p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>{msg}</p>
</div>
)
}
/* ---- Loading Skeleton ---- */
function TableSkeleton() {
return (
<div
className="rounded-[var(--radius-card)] overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-secondary)', boxShadow: 'var(--shadow-card)' }}
>
<div className="h-11" style={{ backgroundColor: 'var(--color-bg-tertiary)', opacity: 0.5 }} />
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 px-4 py-4 animate-pulse"
style={{ borderBottom: '0.5px solid var(--color-separator)' }}
>
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-[var(--radius-control)]" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-24 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div>
<div className="h-4 w-32 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-20 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-6 w-12 rounded-full" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div>
))}
</div>
)
}

View File

@@ -1,332 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { PeerInfo } from '../bindings'
import SearchInput from '../components/ui/SearchInput'
import Button from '../components/ui/Button'
import StatusBadge from '../components/ui/StatusBadge'
import { TableContainer, TableHeader, TableHeaderCell, TableRow, TableCell, TableFooter } from '../components/ui/Table'
type SortKey = 'fqdn' | 'ip' | 'status' | 'latency'
type SortDir = 'asc' | 'desc'
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
function formatLatency(ms: number): string {
if (ms <= 0) return '\u2014'
if (ms < 1) return '<1 ms'
return `${ms.toFixed(1)} ms`
}
function peerName(p: PeerInfo): string {
if (p.fqdn) return p.fqdn.replace(/\.netbird\.cloud\.?$/, '')
return p.ip || p.pubKey.substring(0, 8)
}
export default function Peers() {
const [peers, setPeers] = useState<PeerInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey>('fqdn')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await invoke<PeerInfo[]>('get_peers')
setPeers(data ?? [])
} catch (e) {
console.error('[Peers] load error:', e)
setError(String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
const id = setInterval(load, 10000)
return () => clearInterval(id)
}, [load])
const connectedCount = useMemo(() => peers.filter(p => p.connStatus === 'Connected').length, [peers])
const filtered = useMemo(() => {
let list = peers
if (search) {
const q = search.toLowerCase()
list = list.filter(p =>
peerName(p).toLowerCase().includes(q) ||
p.ip?.toLowerCase().includes(q) ||
p.connStatus?.toLowerCase().includes(q) ||
p.fqdn?.toLowerCase().includes(q)
)
}
return [...list].sort((a, b) => {
let cmp = 0
switch (sortKey) {
case 'fqdn': cmp = peerName(a).localeCompare(peerName(b)); break
case 'ip': cmp = (a.ip ?? '').localeCompare(b.ip ?? ''); break
case 'status': cmp = (a.connStatus ?? '').localeCompare(b.connStatus ?? ''); break
case 'latency': cmp = (a.latencyMs ?? 0) - (b.latencyMs ?? 0); break
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [peers, search, sortKey, sortDir])
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('asc')
}
}
return (
<div className="max-w-5xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Peers</h1>
{/* Toolbar */}
<div className="flex items-center gap-3 mb-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by name, IP or status..."
className="flex-1 max-w-sm"
/>
<div className="flex gap-2 ml-auto">
<Button variant="secondary" size="sm" onClick={load}>Refresh</Button>
</div>
</div>
{error && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[12px]"
style={{ backgroundColor: 'var(--color-status-red-bg)', color: 'var(--color-status-red)' }}
>
{error}
</div>
)}
{peers.length > 0 && (
<div className="mb-3 text-[12px]" style={{ color: 'var(--color-text-tertiary)' }}>
{connectedCount} of {peers.length} peer{peers.length !== 1 ? 's' : ''} connected
</div>
)}
{loading && peers.length === 0 ? (
<TableSkeleton />
) : peers.length === 0 ? (
<EmptyState />
) : filtered.length === 0 ? (
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>
No peers match your search.
<button onClick={() => setSearch('')} className="ml-2 hover:underline" style={{ color: 'var(--color-accent)' }}>Clear search</button>
</div>
) : (
<TableContainer>
<table className="w-full text-[13px]">
<TableHeader>
<SortableHeader label="Peer" sortKey="fqdn" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="IP" sortKey="ip" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="Status" sortKey="status" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<TableHeaderCell>Connection</TableHeaderCell>
<SortableHeader label="Latency" sortKey="latency" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<TableHeaderCell>Transfer</TableHeaderCell>
</TableHeader>
<tbody>
{filtered.map(p => (
<PeerRow key={p.pubKey} peer={p} />
))}
</tbody>
</table>
<TableFooter>
Showing {filtered.length} of {peers.length} peer{peers.length !== 1 ? 's' : ''}
</TableFooter>
</TableContainer>
)}
</div>
)
}
/* ---- Row ---- */
function PeerRow({ peer }: { peer: PeerInfo }) {
const name = peerName(peer)
const connected = peer.connStatus === 'Connected'
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-3 min-w-[160px]">
<PeerSquare name={name} connected={connected} />
<div className="flex flex-col">
<span className="font-medium text-[13px] truncate max-w-[200px]" style={{ color: 'var(--color-text-primary)' }} title={peer.fqdn}>{name}</span>
{peer.networks && peer.networks.length > 0 && (
<span className="text-[11px] mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>{peer.networks.length} network{peer.networks.length !== 1 ? 's' : ''}</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<span className="font-mono text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>{peer.ip || '\u2014'}</span>
</TableCell>
<TableCell>
<StatusBadge status={peer.connStatus} />
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
{connected ? (
<>
<span className="text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>
{peer.relayed ? 'Relayed' : 'Direct'}{' '}
{peer.rosenpassEnabled && (
<span style={{ color: 'var(--color-status-green)' }} title="Rosenpass post-quantum security enabled">PQ</span>
)}
</span>
{peer.relayed && peer.relayAddress && (
<span className="text-[11px] font-mono" style={{ color: 'var(--color-text-tertiary)' }} title={peer.relayAddress}>
via {peer.relayAddress.length > 24 ? peer.relayAddress.substring(0, 24) + '...' : peer.relayAddress}
</span>
)}
{!peer.relayed && peer.localIceType && (
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>{peer.localIceType} / {peer.remoteIceType}</span>
)}
</>
) : (
<span style={{ color: 'var(--color-text-quaternary)' }}>{'\u2014'}</span>
)}
</div>
</TableCell>
<TableCell>
<span className="text-[13px]" style={{ color: peer.latencyMs > 0 ? 'var(--color-text-secondary)' : 'var(--color-text-quaternary)' }}>
{formatLatency(peer.latencyMs)}
</span>
</TableCell>
<TableCell>
{(peer.bytesRx > 0 || peer.bytesTx > 0) ? (
<div className="flex flex-col gap-0.5 text-[11px]">
<span style={{ color: 'var(--color-text-tertiary)' }}>
<span style={{ color: 'var(--color-status-green)' }} title="Received">&#8595;</span> {formatBytes(peer.bytesRx)}
</span>
<span style={{ color: 'var(--color-text-tertiary)' }}>
<span style={{ color: 'var(--color-accent)' }} title="Sent">&#8593;</span> {formatBytes(peer.bytesTx)}
</span>
</div>
) : (
<span style={{ color: 'var(--color-text-quaternary)' }}>{'\u2014'}</span>
)}
</TableCell>
</TableRow>
)
}
/* ---- Peer Icon Square ---- */
function PeerSquare({ name, connected }: { name: string; connected: boolean }) {
const initials = name.substring(0, 2).toUpperCase()
return (
<div
className="relative h-10 w-10 shrink-0 rounded-[var(--radius-control)] flex items-center justify-center text-[13px] font-medium uppercase"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
color: 'var(--color-text-primary)',
}}
>
{initials}
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full"
style={{
backgroundColor: connected ? 'var(--color-status-green)' : 'var(--color-status-gray)',
border: '2px solid var(--color-bg-secondary)',
}}
/>
</div>
)
}
/* ---- Sortable Header ---- */
function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
label: string; sortKey: SortKey; currentKey: SortKey; dir: SortDir; onSort: (k: SortKey) => void
}) {
const isActive = currentKey === sortKey
return (
<TableHeaderCell onClick={() => onSort(sortKey)}>
<span className="inline-flex items-center gap-1">
{label}
{isActive && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{dir === 'asc' ? <path d="M5 15l7-7 7 7" /> : <path d="M19 9l-7 7-7-7" />}
</svg>
)}
</span>
</TableHeaderCell>
)
}
/* ---- Empty State ---- */
function EmptyState() {
return (
<div
className="rounded-[var(--radius-card)] py-16 flex flex-col items-center gap-3"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
}}
>
<div
className="h-12 w-12 rounded-[var(--radius-card)] flex items-center justify-center"
style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
>
<svg className="w-6 h-6" style={{ color: 'var(--color-text-tertiary)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</div>
<p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>No peers found. Connect to a network to see peers.</p>
</div>
)
}
/* ---- Loading Skeleton ---- */
function TableSkeleton() {
return (
<div
className="rounded-[var(--radius-card)] overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-secondary)', boxShadow: 'var(--shadow-card)' }}
>
<div className="h-11" style={{ backgroundColor: 'var(--color-bg-tertiary)', opacity: 0.5 }} />
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 px-4 py-4 animate-pulse"
style={{ borderBottom: '0.5px solid var(--color-separator)' }}
>
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-[var(--radius-control)]" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-28 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div>
<div className="h-4 w-24 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-5 w-20 rounded-full" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-14 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div>
))}
</div>
)
}

View File

@@ -1,168 +0,0 @@
import { useState, useEffect } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { ProfileInfo } from '../bindings'
import Card from '../components/ui/Card'
import CardRow from '../components/ui/CardRow'
import Button from '../components/ui/Button'
import Input from '../components/ui/Input'
import Modal from '../components/ui/Modal'
export default function Profiles() {
const [profiles, setProfiles] = useState<ProfileInfo[]>([])
const [newName, setNewName] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [confirm, setConfirm] = useState<{ action: string; profile: string } | null>(null)
async function refresh() {
try {
const data = await invoke<ProfileInfo[]>('list_profiles')
setProfiles(data ?? [])
} catch (e) {
console.error('[Profiles] ListProfiles error:', e)
setError(String(e))
}
}
useEffect(() => { refresh() }, [])
function showInfo(msg: string) {
setInfo(msg)
setTimeout(() => setInfo(null), 3000)
}
async function handleConfirm() {
if (!confirm) return
setLoading(true)
setError(null)
try {
if (confirm.action === 'switch') await invoke('switch_profile', { profileName: confirm.profile })
else if (confirm.action === 'remove') await invoke('remove_profile', { profileName: confirm.profile })
else if (confirm.action === 'logout') await invoke('logout', { profileName: confirm.profile })
showInfo(`${confirm.action === 'switch' ? 'Switched to' : confirm.action === 'remove' ? 'Removed' : 'Deregistered from'} profile '${confirm.profile}'`)
await refresh()
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
setConfirm(null)
}
}
async function handleAdd() {
if (!newName.trim()) return
setLoading(true)
setError(null)
try {
await invoke('add_profile', { profileName: newName.trim() })
showInfo(`Profile '${newName.trim()}' created`)
setNewName('')
await refresh()
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
function confirmTitle(): string {
if (!confirm) return ''
if (confirm.action === 'switch') return 'Switch Profile'
if (confirm.action === 'remove') return 'Remove Profile'
return 'Deregister Profile'
}
function confirmMessage(): string {
if (!confirm) return ''
if (confirm.action === 'switch') return `Switch to profile '${confirm.profile}'?`
if (confirm.action === 'remove') return `Delete profile '${confirm.profile}'? This cannot be undone.`
return `Deregister from '${confirm.profile}'?`
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Profiles</h1>
{error && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{ backgroundColor: 'var(--color-status-red-bg)', color: 'var(--color-status-red)' }}
>
{error}
</div>
)}
{info && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{ backgroundColor: 'var(--color-status-green-bg)', color: 'var(--color-status-green)' }}
>
{info}
</div>
)}
{confirm && (
<Modal
title={confirmTitle()}
message={confirmMessage()}
destructive={confirm.action === 'remove'}
loading={loading}
onConfirm={handleConfirm}
onCancel={() => setConfirm(null)}
/>
)}
{/* Profile list */}
<Card label="PROFILES" className="mb-6">
{profiles.length === 0 ? (
<div className="p-4 text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>No profiles found.</div>
) : (
profiles.map(p => (
<CardRow key={p.name} label={p.name}>
<div className="flex items-center gap-2">
{p.isActive && (
<span
className="text-[11px] px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: 'var(--color-status-green-bg)',
color: 'var(--color-status-green)',
}}
>
Active
</span>
)}
{!p.isActive && (
<Button variant="primary" size="sm" onClick={() => setConfirm({ action: 'switch', profile: p.name })}>
Select
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => setConfirm({ action: 'logout', profile: p.name })}>
Deregister
</Button>
<Button variant="destructive" size="sm" onClick={() => setConfirm({ action: 'remove', profile: p.name })}>
Remove
</Button>
</div>
</CardRow>
))
)}
</Card>
{/* Add new profile */}
<Card label="ADD PROFILE">
<div className="flex items-center gap-3 px-4 py-3">
<Input
className="flex-1"
placeholder="New profile name"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd()}
/>
<Button onClick={handleAdd} disabled={!newName.trim() || loading} size="sm">
Add
</Button>
</div>
</Card>
</div>
)
}

View File

@@ -1,171 +0,0 @@
import { useState, useEffect } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { ConfigInfo } from '../bindings'
import Card from '../components/ui/Card'
import CardRow from '../components/ui/CardRow'
import Toggle from '../components/ui/Toggle'
import Input from '../components/ui/Input'
import Button from '../components/ui/Button'
import SegmentedControl from '../components/ui/SegmentedControl'
async function getConfig(): Promise<ConfigInfo | null> {
try {
return await invoke<ConfigInfo>('get_config')
} catch (e) {
console.error('[Settings] GetConfig error:', e)
return null
}
}
async function setConfig(cfg: ConfigInfo): Promise<void> {
await invoke('set_config', { cfg })
}
type Tab = 'connection' | 'network' | 'security'
const tabOptions: { value: Tab; label: string }[] = [
{ value: 'connection', label: 'Connection' },
{ value: 'network', label: 'Network' },
{ value: 'security', label: 'Security' },
]
export default function Settings() {
const [config, setConfigState] = useState<ConfigInfo | null>(null)
const [tab, setTab] = useState<Tab>('connection')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getConfig().then(c => { if (c) setConfigState(c) })
}, [])
function update<K extends keyof ConfigInfo>(key: K, value: ConfigInfo[K]) {
setConfigState(prev => prev ? { ...prev, [key]: value } : prev)
}
async function handleSave() {
if (!config) return
setSaving(true)
setError(null)
setSaved(false)
try {
await setConfig(config)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
setError(String(e))
} finally {
setSaving(false)
}
}
if (!config) {
return <div style={{ color: 'var(--color-text-secondary)' }}>Loading settings\u2026</div>
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Settings</h1>
<SegmentedControl options={tabOptions} value={tab} onChange={setTab} className="mb-6" />
{tab === 'connection' && (
<>
<Card label="SERVER CONFIGURATION" className="mb-5">
<CardRow label="Management URL">
<Input
value={config.managementUrl}
onChange={e => update('managementUrl', e.target.value)}
placeholder="https://api.netbird.io:443"
style={{ width: 240 }}
/>
</CardRow>
<CardRow label="Admin URL">
<Input
value={config.adminUrl}
onChange={e => update('adminUrl', e.target.value)}
style={{ width: 240 }}
/>
</CardRow>
<CardRow label="Pre-shared Key">
<Input
type="password"
value={config.preSharedKey}
onChange={e => update('preSharedKey', e.target.value)}
placeholder="Leave empty to clear"
style={{ width: 240 }}
/>
</CardRow>
</Card>
<Card label="BEHAVIOR" className="mb-5">
<CardRow label="Connect automatically">
<Toggle checked={!config.disableAutoConnect} onChange={v => update('disableAutoConnect', !v)} />
</CardRow>
<CardRow label="Enable notifications">
<Toggle checked={!config.disableNotifications} onChange={v => update('disableNotifications', !v)} />
</CardRow>
</Card>
</>
)}
{tab === 'network' && (
<>
<Card label="INTERFACE" className="mb-5">
<CardRow label="Interface Name">
<Input
value={config.interfaceName}
onChange={e => update('interfaceName', e.target.value)}
placeholder="netbird0"
style={{ width: 180 }}
/>
</CardRow>
<CardRow label="WireGuard Port">
<Input
type="number"
min={1}
max={65535}
value={config.wireguardPort}
onChange={e => update('wireguardPort', parseInt(e.target.value) || 0)}
placeholder="51820"
style={{ width: 100 }}
/>
</CardRow>
</Card>
<Card label="OPTIONS" className="mb-5">
<CardRow label="Lazy connections" description="Experimental">
<Toggle checked={config.lazyConnectionEnabled} onChange={v => update('lazyConnectionEnabled', v)} />
</CardRow>
<CardRow label="Block inbound connections">
<Toggle checked={config.blockInbound} onChange={v => update('blockInbound', v)} />
</CardRow>
</Card>
</>
)}
{tab === 'security' && (
<Card label="SECURITY" className="mb-5">
<CardRow label="Allow SSH connections">
<Toggle checked={config.serverSshAllowed} onChange={v => update('serverSshAllowed', v)} />
</CardRow>
<CardRow label="Rosenpass post-quantum security">
<Toggle checked={config.rosenpassEnabled} onChange={v => update('rosenpassEnabled', v)} />
</CardRow>
<CardRow label="Rosenpass permissive mode">
<Toggle checked={config.rosenpassPermissive} onChange={v => update('rosenpassPermissive', v)} />
</CardRow>
</Card>
)}
<div className="flex items-center gap-3">
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving\u2026' : 'Save'}
</Button>
{saved && <span className="text-[13px]" style={{ color: 'var(--color-status-green)' }}>Saved!</span>}
{error && <span className="text-[13px]" style={{ color: 'var(--color-status-red)' }}>{error}</span>}
</div>
</div>
)
}

View File

@@ -1,152 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import type { StatusInfo } from '../bindings'
import Card from '../components/ui/Card'
import CardRow from '../components/ui/CardRow'
import Button from '../components/ui/Button'
async function getStatus(): Promise<StatusInfo | null> {
try {
return await invoke<StatusInfo>('get_status')
} catch (e) {
console.error('[Dashboard] GetStatus error:', e)
return null
}
}
function statusDotColor(status: string): string {
switch (status) {
case 'Connected': return 'var(--color-status-green)'
case 'Connecting': return 'var(--color-status-yellow)'
case 'Disconnected': return 'var(--color-status-gray)'
default: return 'var(--color-status-red)'
}
}
function statusTextColor(status: string): string {
switch (status) {
case 'Connected': return 'var(--color-status-green)'
case 'Connecting': return 'var(--color-status-yellow)'
case 'Disconnected': return 'var(--color-text-secondary)'
default: return 'var(--color-status-red)'
}
}
export default function Status() {
const [status, setStatus] = useState<StatusInfo | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
const s = await getStatus()
if (s) setStatus(s)
}, [])
useEffect(() => {
refresh()
const id = setInterval(refresh, 10000)
const unlisten = listen<StatusInfo>('status-changed', (event) => {
if (event.payload) setStatus(event.payload)
})
return () => {
clearInterval(id)
unlisten.then(fn => fn())
}
}, [refresh])
async function handleConnect() {
setBusy(true)
setError(null)
try {
await invoke('connect')
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
async function handleDisconnect() {
setBusy(true)
setError(null)
try {
await invoke('disconnect')
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
const isConnected = status?.status === 'Connected'
const isConnecting = status?.status === 'Connecting'
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Status</h1>
{/* Status hero */}
<Card className="mb-6">
<div className="px-4 py-5">
<div className="flex items-center gap-3 mb-4">
<span
className={`w-3 h-3 rounded-full ${status?.status === 'Connecting' ? 'animate-pulse' : ''}`}
style={{ backgroundColor: status ? statusDotColor(status.status) : 'var(--color-status-gray)' }}
/>
<span
className="text-xl font-semibold"
style={{ color: status ? statusTextColor(status.status) : 'var(--color-text-secondary)' }}
>
{status?.status ?? 'Loading\u2026'}
</span>
</div>
</div>
{status?.ip && (
<CardRow label="IP Address">
<span className="font-mono text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>{status.ip}</span>
</CardRow>
)}
{status?.fqdn && (
<CardRow label="Hostname">
<span className="font-mono text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>{status.fqdn}</span>
</CardRow>
)}
{status && status.connectedPeers > 0 && (
<CardRow label="Connected Peers">
<span style={{ color: 'var(--color-text-secondary)' }}>{status.connectedPeers}</span>
</CardRow>
)}
</Card>
{/* Actions */}
<div className="flex gap-3">
{!isConnected && !isConnecting && (
<Button onClick={handleConnect} disabled={busy}>
{busy ? 'Connecting\u2026' : 'Connect'}
</Button>
)}
{(isConnected || isConnecting) && (
<Button variant="secondary" onClick={handleDisconnect} disabled={busy}>
{busy ? 'Disconnecting\u2026' : 'Disconnect'}
</Button>
)}
</div>
{error && (
<div
className="mt-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{
backgroundColor: 'var(--color-status-red-bg)',
color: 'var(--color-status-red)',
}}
>
{error}
</div>
)}
</div>
)
}

View File

@@ -1,103 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { InstallerResult } from '../bindings'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
type UpdateState = 'idle' | 'triggering' | 'polling' | 'success' | 'failed' | 'timeout'
export default function Update() {
const [state, setState] = useState<UpdateState>('idle')
const [dots, setDots] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
if (state !== 'polling') return
let count = 0
const id = setInterval(() => {
count = (count + 1) % 4
setDots('.'.repeat(count))
}, 500)
return () => clearInterval(id)
}, [state])
async function handleTriggerUpdate() {
abortRef.current?.abort()
abortRef.current = new AbortController()
setState('triggering')
setErrorMsg('')
try {
await invoke('trigger_update')
} catch (e) {
console.error('[Update] TriggerUpdate error:', e)
setErrorMsg(String(e))
setState('failed')
return
}
setState('polling')
try {
const result = await invoke<InstallerResult>('get_installer_result')
if (result?.success) {
setState('success')
} else {
setErrorMsg(result?.errorMsg ?? 'Update failed')
setState('failed')
}
} catch {
setState('success')
}
}
return (
<div className="max-w-lg mx-auto">
<h1 className="text-xl font-semibold mb-1" style={{ color: 'var(--color-text-primary)' }}>Update</h1>
<p className="text-[13px] mb-8" style={{ color: 'var(--color-text-secondary)' }}>
Trigger an automatic client update managed by the NetBird daemon.
</p>
<Card>
<div className="px-6 py-8 text-center">
{state === 'idle' && (
<>
<p className="text-[13px] mb-5" style={{ color: 'var(--color-text-secondary)' }}>Click below to trigger a daemon-managed update.</p>
<Button onClick={handleTriggerUpdate}>Trigger Update</Button>
</>
)}
{state === 'triggering' && (
<p className="animate-pulse text-[15px]" style={{ color: 'var(--color-status-yellow)' }}>Triggering update\u2026</p>
)}
{state === 'polling' && (
<div>
<p className="text-[17px] mb-2" style={{ color: 'var(--color-status-yellow)' }}>Updating{dots}</p>
<p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>The daemon is installing the update. Please wait.</p>
</div>
)}
{state === 'success' && (
<div>
<p className="text-[17px] font-semibold mb-2" style={{ color: 'var(--color-status-green)' }}>Update Successful!</p>
<p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>The client has been updated. You may need to restart.</p>
</div>
)}
{state === 'failed' && (
<div>
<p className="text-[17px] font-semibold mb-2" style={{ color: 'var(--color-status-red)' }}>Update Failed</p>
{errorMsg && <p className="text-[13px] mb-4" style={{ color: 'var(--color-text-secondary)' }}>{errorMsg}</p>}
<Button variant="secondary" onClick={() => { setState('idle'); setErrorMsg('') }}>
Try Again
</Button>
</div>
)}
</div>
</Card>
</div>
)
}

View File

@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -1,20 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
build: {
outDir: 'dist',
emptyOutDir: true,
},
// Tauri dev server expects clearScreen false
clearScreen: false,
server: {
strictPort: true,
},
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
[package]
name = "netbird-ui"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
tonic-build = "0.12"
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2"
notify-rust = "4"
tonic = "0.12"
prost = "0.13"
prost-types = "0.13"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
env_logger = "0.11"
tower = "0.5"
hyper-util = "0.1"
http = "1"
[target.'cfg(unix)'.dependencies]
tokio-stream = "0.1"
tower = "0.5"
hyper-util = "0.1"

View File

@@ -1,7 +0,0 @@
fn main() {
// Compile the daemon.proto for tonic gRPC client
tonic_build::compile_protos("../../proto/daemon.proto")
.expect("Failed to compile daemon.proto");
tauri_build::build();
}

View File

@@ -1,10 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri/capabilities-schema.json",
"identifier": "default",
"description": "Default capabilities for the NetBird UI",
"windows": ["main"],
"permissions": [
"core:default",
"core:tray:default"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capabilities for the NetBird UI","local":true,"windows":["main"],"permissions":["core:default","core:tray:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -1,72 +0,0 @@
use serde::Serialize;
use tauri::State;
use crate::proto;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusInfo {
pub status: String,
pub ip: String,
pub public_key: String,
pub fqdn: String,
pub connected_peers: usize,
}
#[tauri::command]
pub async fn get_status(state: State<'_, AppState>) -> Result<StatusInfo, String> {
let mut client = state.grpc.get_client().await?;
let resp = client
.status(proto::StatusRequest {
get_full_peer_status: true,
should_run_probes: false,
wait_for_ready: None,
})
.await
.map_err(|e| format!("status rpc: {}", e))?
.into_inner();
let mut info = StatusInfo {
status: resp.status,
ip: String::new(),
public_key: String::new(),
fqdn: String::new(),
connected_peers: 0,
};
if let Some(ref full) = resp.full_status {
if let Some(ref lp) = full.local_peer_state {
info.ip = lp.ip.clone();
info.public_key = lp.pub_key.clone();
info.fqdn = lp.fqdn.clone();
}
info.connected_peers = full.peers.len();
}
Ok(info)
}
#[tauri::command]
pub async fn connect(state: State<'_, AppState>) -> Result<(), String> {
let mut client = state.grpc.get_client().await?;
client
.up(proto::UpRequest {
profile_name: None,
username: None,
auto_update: None,
})
.await
.map_err(|e| format!("connect: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
let mut client = state.grpc.get_client().await?;
client
.down(proto::DownRequest {})
.await
.map_err(|e| format!("disconnect: {}", e))?;
Ok(())
}

View File

@@ -1,188 +0,0 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::proto;
use crate::state::AppState;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DebugBundleParams {
pub anonymize: bool,
pub system_info: bool,
pub upload: bool,
pub upload_url: String,
pub run_duration_mins: u32,
pub enable_persistence: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DebugBundleResult {
pub local_path: String,
pub uploaded_key: String,
pub upload_failure_reason: String,
}
#[tauri::command]
pub async fn create_debug_bundle(
state: State<'_, AppState>,
params: DebugBundleParams,
) -> Result<DebugBundleResult, String> {
let mut client = state.grpc.get_client().await?;
// If run_duration_mins > 0, do the full debug cycle
if params.run_duration_mins > 0 {
configure_for_debug(&mut client, &params).await?;
}
let upload_url = if params.upload && !params.upload_url.is_empty() {
params.upload_url.clone()
} else {
String::new()
};
let resp = client
.debug_bundle(proto::DebugBundleRequest {
anonymize: params.anonymize,
system_info: params.system_info,
upload_url: upload_url,
log_file_count: 0,
})
.await
.map_err(|e| format!("create debug bundle: {}", e))?
.into_inner();
Ok(DebugBundleResult {
local_path: resp.path,
uploaded_key: resp.uploaded_key,
upload_failure_reason: resp.upload_failure_reason,
})
}
async fn configure_for_debug(
client: &mut proto::daemon_service_client::DaemonServiceClient<tonic::transport::Channel>,
params: &DebugBundleParams,
) -> Result<(), String> {
// Get current status
let status_resp = client
.status(proto::StatusRequest {
get_full_peer_status: false,
should_run_probes: false,
wait_for_ready: None,
})
.await
.map_err(|e| format!("get status: {}", e))?
.into_inner();
let was_connected =
status_resp.status == "Connected" || status_resp.status == "Connecting";
// Get current log level
let log_resp = client
.get_log_level(proto::GetLogLevelRequest {})
.await
.map_err(|e| format!("get log level: {}", e))?
.into_inner();
let original_level = log_resp.level;
// Set trace log level
client
.set_log_level(proto::SetLogLevelRequest {
level: proto::LogLevel::Trace.into(),
})
.await
.map_err(|e| format!("set log level: {}", e))?;
// Bring down then up
let _ = client.down(proto::DownRequest {}).await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if params.enable_persistence {
let _ = client
.set_sync_response_persistence(proto::SetSyncResponsePersistenceRequest {
enabled: true,
})
.await;
}
client
.up(proto::UpRequest {
profile_name: None,
username: None,
auto_update: None,
})
.await
.map_err(|e| format!("bring service up: {}", e))?;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let _ = client
.start_cpu_profile(proto::StartCpuProfileRequest {})
.await;
// Wait for collection duration
let duration = std::time::Duration::from_secs(params.run_duration_mins as u64 * 60);
tokio::time::sleep(duration).await;
let _ = client
.stop_cpu_profile(proto::StopCpuProfileRequest {})
.await;
// Restore original state
if !was_connected {
let _ = client.down(proto::DownRequest {}).await;
}
if original_level < proto::LogLevel::Trace as i32 {
let _ = client
.set_log_level(proto::SetLogLevelRequest {
level: original_level,
})
.await;
}
Ok(())
}
#[tauri::command]
pub async fn get_log_level(state: State<'_, AppState>) -> Result<String, String> {
let mut client = state.grpc.get_client().await?;
let resp = client
.get_log_level(proto::GetLogLevelRequest {})
.await
.map_err(|e| format!("get log level rpc: {}", e))?
.into_inner();
let level_name = match proto::LogLevel::try_from(resp.level) {
Ok(proto::LogLevel::Trace) => "TRACE",
Ok(proto::LogLevel::Debug) => "DEBUG",
Ok(proto::LogLevel::Info) => "INFO",
Ok(proto::LogLevel::Warn) => "WARN",
Ok(proto::LogLevel::Error) => "ERROR",
Ok(proto::LogLevel::Fatal) => "FATAL",
Ok(proto::LogLevel::Panic) => "PANIC",
_ => "UNKNOWN",
};
Ok(level_name.to_string())
}
#[tauri::command]
pub async fn set_log_level(state: State<'_, AppState>, level: String) -> Result<(), String> {
let proto_level = match level.as_str() {
"TRACE" => proto::LogLevel::Trace,
"DEBUG" => proto::LogLevel::Debug,
"INFO" => proto::LogLevel::Info,
"WARN" | "WARNING" => proto::LogLevel::Warn,
"ERROR" => proto::LogLevel::Error,
_ => proto::LogLevel::Info,
};
let mut client = state.grpc.get_client().await?;
client
.set_log_level(proto::SetLogLevelRequest {
level: proto_level.into(),
})
.await
.map_err(|e| format!("set log level rpc: {}", e))?;
Ok(())
}

View File

@@ -1,7 +0,0 @@
pub mod connection;
pub mod debug;
pub mod network;
pub mod peers;
pub mod profile;
pub mod settings;
pub mod update;

View File

@@ -1,164 +0,0 @@
use std::collections::HashMap;
use serde::Serialize;
use tauri::State;
use crate::proto;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkInfo {
pub id: String,
pub range: String,
pub domains: Vec<String>,
pub selected: bool,
#[serde(rename = "resolvedIPs")]
pub resolved_ips: HashMap<String, Vec<String>>,
}
fn network_from_proto(r: &proto::Network) -> NetworkInfo {
let mut resolved = HashMap::new();
for (domain, ip_list) in &r.resolved_i_ps {
resolved.insert(domain.clone(), ip_list.ips.clone());
}
NetworkInfo {
id: r.id.clone(),
range: r.range.clone(),
domains: r.domains.clone(),
selected: r.selected,
resolved_ips: resolved,
}
}
async fn fetch_networks(state: &State<'_, AppState>) -> Result<Vec<NetworkInfo>, String> {
let mut client = state.grpc.get_client().await?;
let resp = client
.list_networks(proto::ListNetworksRequest {})
.await
.map_err(|e| format!("list networks rpc: {}", e))?
.into_inner();
let mut routes: Vec<NetworkInfo> = resp.routes.iter().map(network_from_proto).collect();
routes.sort_by(|a, b| a.id.to_lowercase().cmp(&b.id.to_lowercase()));
Ok(routes)
}
#[tauri::command]
pub async fn list_networks(state: State<'_, AppState>) -> Result<Vec<NetworkInfo>, String> {
fetch_networks(&state).await
}
#[tauri::command]
pub async fn list_overlapping_networks(
state: State<'_, AppState>,
) -> Result<Vec<NetworkInfo>, String> {
let all = fetch_networks(&state).await?;
let mut by_range: HashMap<String, Vec<NetworkInfo>> = HashMap::new();
for r in all {
if !r.domains.is_empty() {
continue;
}
by_range.entry(r.range.clone()).or_default().push(r);
}
let mut result = Vec::new();
for group in by_range.values() {
if group.len() > 1 {
result.extend(group.iter().cloned());
}
}
Ok(result)
}
#[tauri::command]
pub async fn list_exit_nodes(state: State<'_, AppState>) -> Result<Vec<NetworkInfo>, String> {
let all = fetch_networks(&state).await?;
Ok(all.into_iter().filter(|r| r.range == "0.0.0.0/0").collect())
}
#[tauri::command]
pub async fn select_network(state: State<'_, AppState>, id: String) -> Result<(), String> {
let mut client = state.grpc.get_client().await?;
client
.select_networks(proto::SelectNetworksRequest {
network_i_ds: vec![id],
append: true,
all: false,
})
.await
.map_err(|e| format!("select network: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn deselect_network(state: State<'_, AppState>, id: String) -> Result<(), String> {
let mut client = state.grpc.get_client().await?;
client
.deselect_networks(proto::SelectNetworksRequest {
network_i_ds: vec![id],
append: false,
all: false,
})
.await
.map_err(|e| format!("deselect network: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn select_networks(state: State<'_, AppState>, ids: Vec<String>) -> Result<(), String> {
let mut client = state.grpc.get_client().await?;
client
.select_networks(proto::SelectNetworksRequest {
network_i_ds: ids,
append: true,
all: false,
})
.await
.map_err(|e| format!("select networks: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn deselect_networks(
state: State<'_, AppState>,
ids: Vec<String>,
) -> Result<(), String> {
let mut client = state.grpc.get_client().await?;
client
.deselect_networks(proto::SelectNetworksRequest {
network_i_ds: ids,
append: false,
all: false,
})
.await
.map_err(|e| format!("deselect networks: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn select_all_networks(state: State<'_, AppState>) -> Result<(), String> {
let mut client = state.grpc.get_client().await?;
client
.select_networks(proto::SelectNetworksRequest {
network_i_ds: vec![],
append: false,
all: true,
})
.await
.map_err(|e| format!("select all networks: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn deselect_all_networks(state: State<'_, AppState>) -> Result<(), String> {
let mut client = state.grpc.get_client().await?;
client
.deselect_networks(proto::SelectNetworksRequest {
network_i_ds: vec![],
append: false,
all: true,
})
.await
.map_err(|e| format!("deselect all networks: {}", e))?;
Ok(())
}

View File

@@ -1,91 +0,0 @@
use serde::Serialize;
use tauri::State;
use crate::proto;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PeerInfo {
pub ip: String,
pub pub_key: String,
pub fqdn: String,
pub conn_status: String,
pub conn_status_update: String,
pub relayed: bool,
pub relay_address: String,
pub latency_ms: f64,
pub bytes_rx: i64,
pub bytes_tx: i64,
pub rosenpass_enabled: bool,
pub networks: Vec<String>,
pub last_handshake: String,
pub local_ice_type: String,
pub remote_ice_type: String,
pub local_endpoint: String,
pub remote_endpoint: String,
}
fn format_timestamp(ts: &Option<prost_types::Timestamp>) -> String {
match ts {
Some(t) => {
// Simple RFC3339-like formatting
let secs = t.seconds;
let nanos = t.nanos;
format!("{}:{}", secs, nanos)
}
None => String::new(),
}
}
#[tauri::command]
pub async fn get_peers(state: State<'_, AppState>) -> Result<Vec<PeerInfo>, String> {
let mut client = state.grpc.get_client().await?;
let resp = client
.status(proto::StatusRequest {
get_full_peer_status: true,
should_run_probes: false,
wait_for_ready: None,
})
.await
.map_err(|e| format!("status rpc: {}", e))?
.into_inner();
let peers = match resp.full_status {
Some(ref full) => &full.peers,
None => return Ok(vec![]),
};
let result: Vec<PeerInfo> = peers
.iter()
.map(|p| {
let latency_ms = p
.latency
.as_ref()
.map(|d| d.seconds as f64 * 1000.0 + d.nanos as f64 / 1_000_000.0)
.unwrap_or(0.0);
PeerInfo {
ip: p.ip.clone(),
pub_key: p.pub_key.clone(),
fqdn: p.fqdn.clone(),
conn_status: p.conn_status.clone(),
conn_status_update: format_timestamp(&p.conn_status_update),
relayed: p.relayed,
relay_address: p.relay_address.clone(),
latency_ms,
bytes_rx: p.bytes_rx,
bytes_tx: p.bytes_tx,
rosenpass_enabled: p.rosenpass_enabled,
networks: p.networks.clone(),
last_handshake: format_timestamp(&p.last_wireguard_handshake),
local_ice_type: p.local_ice_candidate_type.clone(),
remote_ice_type: p.remote_ice_candidate_type.clone(),
local_endpoint: p.local_ice_candidate_endpoint.clone(),
remote_endpoint: p.remote_ice_candidate_endpoint.clone(),
}
})
.collect();
Ok(result)
}

View File

@@ -1,135 +0,0 @@
use serde::Serialize;
use tauri::State;
use crate::proto;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInfo {
pub name: String,
pub is_active: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ActiveProfileInfo {
pub profile_name: String,
pub username: String,
pub email: String,
}
fn current_username() -> Result<String, String> {
#[cfg(unix)]
{
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.map_err(|_| "could not determine current user".to_string())
}
#[cfg(windows)]
{
std::env::var("USERNAME")
.map_err(|_| "could not determine current user".to_string())
}
}
#[tauri::command]
pub async fn list_profiles(state: State<'_, AppState>) -> Result<Vec<ProfileInfo>, String> {
let username = current_username()?;
let mut client = state.grpc.get_client().await?;
let resp = client
.list_profiles(proto::ListProfilesRequest { username })
.await
.map_err(|e| format!("list profiles rpc: {}", e))?
.into_inner();
Ok(resp
.profiles
.iter()
.map(|p| ProfileInfo {
name: p.name.clone(),
is_active: p.is_active,
})
.collect())
}
#[tauri::command]
pub async fn get_active_profile(state: State<'_, AppState>) -> Result<ActiveProfileInfo, String> {
let mut client = state.grpc.get_client().await?;
let resp = client
.get_active_profile(proto::GetActiveProfileRequest {})
.await
.map_err(|e| format!("get active profile rpc: {}", e))?
.into_inner();
Ok(ActiveProfileInfo {
profile_name: resp.profile_name,
username: resp.username,
email: String::new(),
})
}
#[tauri::command]
pub async fn switch_profile(
state: State<'_, AppState>,
profile_name: String,
) -> Result<(), String> {
let username = current_username()?;
let mut client = state.grpc.get_client().await?;
client
.switch_profile(proto::SwitchProfileRequest {
profile_name: Some(profile_name),
username: Some(username),
})
.await
.map_err(|e| format!("switch profile: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn add_profile(
state: State<'_, AppState>,
profile_name: String,
) -> Result<(), String> {
let username = current_username()?;
let mut client = state.grpc.get_client().await?;
client
.add_profile(proto::AddProfileRequest {
profile_name,
username,
})
.await
.map_err(|e| format!("add profile: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn remove_profile(
state: State<'_, AppState>,
profile_name: String,
) -> Result<(), String> {
let username = current_username()?;
let mut client = state.grpc.get_client().await?;
client
.remove_profile(proto::RemoveProfileRequest {
profile_name,
username,
})
.await
.map_err(|e| format!("remove profile: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn logout(state: State<'_, AppState>, profile_name: String) -> Result<(), String> {
let username = current_username()?;
let mut client = state.grpc.get_client().await?;
client
.logout(proto::LogoutRequest {
profile_name: Some(profile_name),
username: Some(username),
})
.await
.map_err(|e| format!("logout: {}", e))?;
Ok(())
}

View File

@@ -1,147 +0,0 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::proto;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigInfo {
pub management_url: String,
pub admin_url: String,
pub pre_shared_key: String,
pub interface_name: String,
pub wireguard_port: i64,
pub disable_auto_connect: bool,
pub server_ssh_allowed: bool,
pub rosenpass_enabled: bool,
pub rosenpass_permissive: bool,
pub lazy_connection_enabled: bool,
pub block_inbound: bool,
pub disable_notifications: bool,
}
#[tauri::command]
pub async fn get_config(state: State<'_, AppState>) -> Result<ConfigInfo, String> {
let mut client = state.grpc.get_client().await?;
let resp = client
.get_config(proto::GetConfigRequest {
profile_name: String::new(),
username: String::new(),
})
.await
.map_err(|e| format!("get config rpc: {}", e))?
.into_inner();
Ok(ConfigInfo {
management_url: resp.management_url,
admin_url: resp.admin_url,
pre_shared_key: resp.pre_shared_key,
interface_name: resp.interface_name,
wireguard_port: resp.wireguard_port,
disable_auto_connect: resp.disable_auto_connect,
server_ssh_allowed: resp.server_ssh_allowed,
rosenpass_enabled: resp.rosenpass_enabled,
rosenpass_permissive: resp.rosenpass_permissive,
lazy_connection_enabled: resp.lazy_connection_enabled,
block_inbound: resp.block_inbound,
disable_notifications: resp.disable_notifications,
})
}
#[tauri::command]
pub async fn set_config(state: State<'_, AppState>, cfg: ConfigInfo) -> Result<(), String> {
let mut client = state.grpc.get_client().await?;
let req = proto::SetConfigRequest {
username: String::new(),
profile_name: String::new(),
management_url: cfg.management_url,
admin_url: cfg.admin_url,
rosenpass_enabled: Some(cfg.rosenpass_enabled),
interface_name: Some(cfg.interface_name),
wireguard_port: Some(cfg.wireguard_port),
optional_pre_shared_key: Some(cfg.pre_shared_key),
disable_auto_connect: Some(cfg.disable_auto_connect),
server_ssh_allowed: Some(cfg.server_ssh_allowed),
rosenpass_permissive: Some(cfg.rosenpass_permissive),
disable_notifications: Some(cfg.disable_notifications),
lazy_connection_enabled: Some(cfg.lazy_connection_enabled),
block_inbound: Some(cfg.block_inbound),
// Fields we don't expose in the UI:
network_monitor: None,
disable_client_routes: None,
disable_server_routes: None,
disable_dns: None,
disable_firewall: None,
block_lan_access: None,
nat_external_i_ps: vec![],
clean_nat_external_i_ps: false,
custom_dns_address: vec![],
extra_i_face_blacklist: vec![],
dns_labels: vec![],
clean_dns_labels: false,
dns_route_interval: None,
mtu: None,
enable_ssh_root: None,
enable_sshsftp: None,
enable_ssh_local_port_forwarding: None,
enable_ssh_remote_port_forwarding: None,
disable_ssh_auth: None,
ssh_jwt_cache_ttl: None,
};
client
.set_config(req)
.await
.map_err(|e| format!("set config: {}", e))?;
Ok(())
}
// Toggle helpers - each fetches config, modifies one field, and saves.
#[tauri::command]
pub async fn toggle_ssh(state: State<'_, AppState>, enabled: bool) -> Result<(), String> {
let mut cfg = get_config(state.clone()).await?;
cfg.server_ssh_allowed = enabled;
set_config(state, cfg).await
}
#[tauri::command]
pub async fn toggle_auto_connect(state: State<'_, AppState>, enabled: bool) -> Result<(), String> {
let mut cfg = get_config(state.clone()).await?;
cfg.disable_auto_connect = !enabled;
set_config(state, cfg).await
}
#[tauri::command]
pub async fn toggle_rosenpass(state: State<'_, AppState>, enabled: bool) -> Result<(), String> {
let mut cfg = get_config(state.clone()).await?;
cfg.rosenpass_enabled = enabled;
set_config(state, cfg).await
}
#[tauri::command]
pub async fn toggle_lazy_conn(state: State<'_, AppState>, enabled: bool) -> Result<(), String> {
let mut cfg = get_config(state.clone()).await?;
cfg.lazy_connection_enabled = enabled;
set_config(state, cfg).await
}
#[tauri::command]
pub async fn toggle_block_inbound(
state: State<'_, AppState>,
enabled: bool,
) -> Result<(), String> {
let mut cfg = get_config(state.clone()).await?;
cfg.block_inbound = enabled;
set_config(state, cfg).await
}
#[tauri::command]
pub async fn toggle_notifications(
state: State<'_, AppState>,
enabled: bool,
) -> Result<(), String> {
let mut cfg = get_config(state.clone()).await?;
cfg.disable_notifications = !enabled;
set_config(state, cfg).await
}

View File

@@ -1,43 +0,0 @@
use serde::Serialize;
use tauri::State;
use crate::proto;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallerResult {
pub success: bool,
pub error_msg: String,
}
#[tauri::command]
pub async fn trigger_update() -> Result<(), String> {
// Stub - same as the Go implementation
Ok(())
}
#[tauri::command]
pub async fn get_installer_result(state: State<'_, AppState>) -> Result<InstallerResult, String> {
let mut client = state.grpc.get_client().await?;
let resp = client
.get_installer_result(proto::InstallerResultRequest {})
.await;
match resp {
Ok(r) => {
let inner = r.into_inner();
Ok(InstallerResult {
success: inner.success,
error_msg: inner.error_msg,
})
}
Err(_) => {
// Daemon may have restarted during update - treat as success
Ok(InstallerResult {
success: true,
error_msg: String::new(),
})
}
}
}

View File

@@ -1,91 +0,0 @@
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use crate::grpc::GrpcClient;
use crate::proto;
/// Start the daemon event subscription loop with exponential backoff.
pub fn start_event_subscription(app: AppHandle, grpc: GrpcClient) {
tauri::async_runtime::spawn(async move {
let mut backoff = Duration::from_secs(1);
let max_backoff = Duration::from_secs(10);
loop {
match stream_events(&app, &grpc).await {
Ok(()) => {
backoff = Duration::from_secs(1);
}
Err(e) => {
log::warn!("event stream ended: {}", e);
}
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(max_backoff);
}
});
}
async fn stream_events(app: &AppHandle, grpc: &GrpcClient) -> Result<(), String> {
let mut client = grpc.get_client().await?;
let mut stream = client
.subscribe_events(proto::SubscribeRequest {})
.await
.map_err(|e| format!("subscribe events: {}", e))?
.into_inner();
log::info!("subscribed to daemon events");
while let Some(event) = stream
.message()
.await
.map_err(|e| format!("receive event: {}", e))?
{
handle_event(app, &event);
}
log::info!("event stream ended");
Ok(())
}
fn handle_event(app: &AppHandle, event: &proto::SystemEvent) {
// Send desktop notification for events with user_message
if !event.user_message.is_empty() {
let title = get_event_title(event);
let mut body = event.user_message.clone();
if let Some(id) = event.metadata.get("id") {
body.push_str(&format!(" ID: {}", id));
}
if let Err(e) = notify_rust::Notification::new()
.summary(&title)
.body(&body)
.appname("NetBird")
.show()
{
log::debug!("notification failed: {}", e);
}
}
// Emit to frontend
let _ = app.emit("daemon-event", &event.user_message);
}
fn get_event_title(event: &proto::SystemEvent) -> String {
let prefix = match proto::system_event::Severity::try_from(event.severity) {
Ok(proto::system_event::Severity::Critical) => "Critical",
Ok(proto::system_event::Severity::Error) => "Error",
Ok(proto::system_event::Severity::Warning) => "Warning",
_ => "Info",
};
let category = match proto::system_event::Category::try_from(event.category) {
Ok(proto::system_event::Category::Dns) => "DNS",
Ok(proto::system_event::Category::Network) => "Network",
Ok(proto::system_event::Category::Authentication) => "Authentication",
Ok(proto::system_event::Category::Connectivity) => "Connectivity",
_ => "System",
};
format!("{}: {}", prefix, category)
}

View File

@@ -1,104 +0,0 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use tonic::transport::{Channel, Endpoint, Uri};
use crate::proto::daemon_service_client::DaemonServiceClient;
/// GrpcClient manages a persistent gRPC connection to the NetBird daemon.
#[derive(Clone)]
pub struct GrpcClient {
addr: String,
client: Arc<Mutex<Option<DaemonServiceClient<Channel>>>>,
}
impl GrpcClient {
pub fn new(addr: String) -> Self {
Self {
addr,
client: Arc::new(Mutex::new(None)),
}
}
/// Returns a cached DaemonServiceClient, creating the connection on first use.
/// If the connection fails or was previously dropped, a new connection is attempted.
pub async fn get_client(&self) -> Result<DaemonServiceClient<Channel>, String> {
let mut guard = self.client.lock().await;
if let Some(ref client) = *guard {
return Ok(client.clone());
}
let channel = self.connect().await?;
let client = DaemonServiceClient::new(channel);
*guard = Some(client.clone());
log::info!("gRPC connection established to {}", self.addr);
Ok(client)
}
/// Clears the cached client so the next call to get_client will reconnect.
pub async fn reset(&self) {
let mut guard = self.client.lock().await;
*guard = None;
}
async fn connect(&self) -> Result<Channel, String> {
let addr = &self.addr;
#[cfg(unix)]
if addr.starts_with("unix://") {
return self.connect_unix(addr).await;
}
// TCP connection
let target = if addr.starts_with("tcp://") {
addr.strip_prefix("tcp://").unwrap_or(addr)
} else {
addr.as_str()
};
let uri = format!("http://{}", target);
Endpoint::from_shared(uri)
.map_err(|e| format!("invalid endpoint: {}", e))?
.connect()
.await
.map_err(|e| format!("connect tcp: {}", e))
}
#[cfg(unix)]
async fn connect_unix(&self, addr: &str) -> Result<Channel, String> {
let path = addr
.strip_prefix("unix://")
.unwrap_or(addr)
.to_string();
// tonic requires a valid URI even for UDS; the actual connection
// is made by the connector below, so the URI authority is ignored.
let channel = Endpoint::try_from("http://[::]:50051")
.map_err(|e| format!("invalid endpoint: {}", e))?
.connect_with_connector(tower::service_fn(move |_: Uri| {
let path = path.clone();
async move {
let stream = tokio::net::UnixStream::connect(&path).await?;
Ok::<_, std::io::Error>(hyper_util::rt::TokioIo::new(stream))
}
}))
.await
.map_err(|e| format!("connect unix: {}", e))?;
Ok(channel)
}
/// Close the connection (drop the cached client).
pub async fn close(&self) {
let mut guard = self.client.lock().await;
*guard = None;
}
}
/// Returns the default daemon address for the current platform.
pub fn default_daemon_addr() -> String {
if cfg!(windows) {
"tcp://127.0.0.1:41731".to_string()
} else {
"unix:///var/run/netbird.sock".to_string()
}
}

View File

@@ -1,106 +0,0 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod commands;
mod events;
mod grpc;
mod proto;
mod state;
mod tray;
use tauri::Manager;
use grpc::{default_daemon_addr, GrpcClient};
use state::AppState;
fn main() {
env_logger::init();
// Linux WebKit workaround
#[cfg(target_os = "linux")]
{
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
let daemon_addr =
std::env::var("NETBIRD_DAEMON_ADDR").unwrap_or_else(|_| default_daemon_addr());
log::info!("NetBird UI starting, daemon address: {}", daemon_addr);
let grpc_client = GrpcClient::new(daemon_addr.clone());
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// Focus existing window when second instance is launched
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}))
.manage(AppState {
grpc: grpc_client.clone(),
})
.invoke_handler(tauri::generate_handler![
// Connection
commands::connection::get_status,
commands::connection::connect,
commands::connection::disconnect,
// Settings
commands::settings::get_config,
commands::settings::set_config,
commands::settings::toggle_ssh,
commands::settings::toggle_auto_connect,
commands::settings::toggle_rosenpass,
commands::settings::toggle_lazy_conn,
commands::settings::toggle_block_inbound,
commands::settings::toggle_notifications,
// Network
commands::network::list_networks,
commands::network::list_overlapping_networks,
commands::network::list_exit_nodes,
commands::network::select_network,
commands::network::deselect_network,
commands::network::select_networks,
commands::network::deselect_networks,
commands::network::select_all_networks,
commands::network::deselect_all_networks,
// Peers
commands::peers::get_peers,
// Profile
commands::profile::list_profiles,
commands::profile::get_active_profile,
commands::profile::switch_profile,
commands::profile::add_profile,
commands::profile::remove_profile,
commands::profile::logout,
// Debug
commands::debug::create_debug_bundle,
commands::debug::get_log_level,
commands::debug::set_log_level,
// Update
commands::update::trigger_update,
commands::update::get_installer_result,
])
.setup(|app| {
let handle = app.handle().clone();
// Setup system tray
if let Err(e) = tray::setup_tray(&handle) {
log::error!("tray setup failed: {}", e);
}
// Start daemon event subscription
events::start_event_subscription(handle, grpc_client);
Ok(())
})
.on_window_event(|window, event| {
// Hide instead of quit when user closes the window
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.run(tauri::generate_context!())
.expect("error running tauri application");
}

View File

@@ -1 +0,0 @@
tonic::include_proto!("daemon");

View File

@@ -1,6 +0,0 @@
use crate::grpc::GrpcClient;
/// Application state shared across all Tauri commands.
pub struct AppState {
pub grpc: GrpcClient,
}

View File

@@ -1,420 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use tauri::image::Image;
use tauri::menu::{CheckMenuItem, CheckMenuItemBuilder, MenuBuilder, MenuItem, MenuItemBuilder, SubmenuBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::Mutex;
use crate::commands::connection::StatusInfo;
use crate::grpc::GrpcClient;
use crate::proto;
use crate::state::AppState;
const STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5);
// Icon bytes embedded at compile time
const ICON_DISCONNECTED: &[u8] = include_bytes!("../icons/netbird-systemtray-disconnected.png");
const ICON_CONNECTED: &[u8] = include_bytes!("../icons/netbird-systemtray-connected.png");
const ICON_CONNECTING: &[u8] = include_bytes!("../icons/netbird-systemtray-connecting.png");
const ICON_ERROR: &[u8] = include_bytes!("../icons/netbird-systemtray-error.png");
fn icon_for_status(status: &str) -> &'static [u8] {
match status {
"Connected" => ICON_CONNECTED,
"Connecting" => ICON_CONNECTING,
"Disconnected" | "" => ICON_DISCONNECTED,
_ => ICON_ERROR,
}
}
/// Holds references to menu items we need to update at runtime.
pub struct TrayMenuItems {
pub status_item: MenuItem<tauri::Wry>,
pub ssh_item: CheckMenuItem<tauri::Wry>,
pub auto_connect_item: CheckMenuItem<tauri::Wry>,
pub rosenpass_item: CheckMenuItem<tauri::Wry>,
pub lazy_conn_item: CheckMenuItem<tauri::Wry>,
pub block_inbound_item: CheckMenuItem<tauri::Wry>,
pub notifications_item: CheckMenuItem<tauri::Wry>,
}
pub type SharedTrayMenuItems = Arc<Mutex<Option<TrayMenuItems>>>;
pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let grpc = app.state::<AppState>().grpc.clone();
// Build the tray menu
let status_item = MenuItemBuilder::with_id("status", "Status: Disconnected")
.enabled(false)
.build(app)?;
let connect_item = MenuItemBuilder::with_id("connect", "Connect").build(app)?;
let disconnect_item = MenuItemBuilder::with_id("disconnect", "Disconnect").build(app)?;
let ssh_item = CheckMenuItemBuilder::with_id("toggle_ssh", "Allow SSH connections")
.checked(false)
.build(app)?;
let auto_connect_item =
CheckMenuItemBuilder::with_id("toggle_auto_connect", "Connect automatically when service starts")
.checked(false)
.build(app)?;
let rosenpass_item =
CheckMenuItemBuilder::with_id("toggle_rosenpass", "Enable post-quantum security via Rosenpass")
.checked(false)
.build(app)?;
let lazy_conn_item =
CheckMenuItemBuilder::with_id("toggle_lazy_conn", "[Experimental] Enable lazy connections")
.checked(false)
.build(app)?;
let block_inbound_item =
CheckMenuItemBuilder::with_id("toggle_block_inbound", "Block inbound connections")
.checked(false)
.build(app)?;
let notifications_item =
CheckMenuItemBuilder::with_id("toggle_notifications", "Enable notifications")
.checked(true)
.build(app)?;
// Exit node submenu
let exit_node_menu = SubmenuBuilder::with_id(app, "exit_node", "Exit Node")
.item(
&MenuItemBuilder::with_id("no_exit_nodes", "No exit nodes")
.enabled(false)
.build(app)?,
)
.build()?;
// Navigation items
let nav_status = MenuItemBuilder::with_id("nav_status", "Status").build(app)?;
let nav_settings = MenuItemBuilder::with_id("nav_settings", "Settings").build(app)?;
let nav_peers = MenuItemBuilder::with_id("nav_peers", "Peers").build(app)?;
let nav_networks = MenuItemBuilder::with_id("nav_networks", "Networks").build(app)?;
let nav_profiles = MenuItemBuilder::with_id("nav_profiles", "Profiles").build(app)?;
let nav_debug = MenuItemBuilder::with_id("nav_debug", "Debug").build(app)?;
let nav_update = MenuItemBuilder::with_id("nav_update", "Update").build(app)?;
let quit_item = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app)
.item(&status_item)
.separator()
.item(&connect_item)
.item(&disconnect_item)
.separator()
.item(&ssh_item)
.item(&auto_connect_item)
.item(&rosenpass_item)
.item(&lazy_conn_item)
.item(&block_inbound_item)
.item(&notifications_item)
.separator()
.item(&exit_node_menu)
.separator()
.item(&nav_status)
.item(&nav_settings)
.item(&nav_peers)
.item(&nav_networks)
.item(&nav_profiles)
.item(&nav_debug)
.item(&nav_update)
.separator()
.item(&quit_item)
.build()?;
// Store menu item references for runtime updates
let menu_items: SharedTrayMenuItems = Arc::new(Mutex::new(Some(TrayMenuItems {
status_item,
ssh_item: ssh_item.clone(),
auto_connect_item: auto_connect_item.clone(),
rosenpass_item: rosenpass_item.clone(),
lazy_conn_item: lazy_conn_item.clone(),
block_inbound_item: block_inbound_item.clone(),
notifications_item: notifications_item.clone(),
})));
app.manage(menu_items.clone());
let _tray = TrayIconBuilder::with_id("main")
.icon(Image::from_bytes(ICON_DISCONNECTED)?)
.icon_as_template(cfg!(target_os = "macos"))
.menu(&menu)
.on_menu_event({
let app_handle = app.clone();
let grpc = grpc.clone();
move |_app, event| {
let id = event.id().as_ref();
let app_handle = app_handle.clone();
let grpc = grpc.clone();
match id {
"connect" => {
tauri::async_runtime::spawn(async move {
let mut client = match grpc.get_client().await {
Ok(c) => c,
Err(e) => {
log::error!("connect: {}", e);
return;
}
};
if let Err(e) = client
.up(proto::UpRequest {
profile_name: None,
username: None,
auto_update: None,
})
.await
{
log::error!("connect: {}", e);
}
});
}
"disconnect" => {
tauri::async_runtime::spawn(async move {
let mut client = match grpc.get_client().await {
Ok(c) => c,
Err(e) => {
log::error!("disconnect: {}", e);
return;
}
};
if let Err(e) = client.down(proto::DownRequest {}).await {
log::error!("disconnect: {}", e);
}
});
}
"toggle_ssh" | "toggle_auto_connect" | "toggle_rosenpass"
| "toggle_lazy_conn" | "toggle_block_inbound" | "toggle_notifications" => {
let toggle_id = id.to_string();
tauri::async_runtime::spawn(async move {
handle_toggle(&app_handle, &grpc, &toggle_id).await;
});
}
s if s.starts_with("nav_") => {
let path = match s {
"nav_status" => "/",
"nav_settings" => "/settings",
"nav_peers" => "/peers",
"nav_networks" => "/networks",
"nav_profiles" => "/profiles",
"nav_debug" => "/debug",
"nav_update" => "/update",
_ => return,
};
let _ = app_handle.emit("navigate", path);
if let Some(win) = app_handle.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
"quit" => {
app_handle.exit(0);
}
_ => {}
}
}
})
.build(app)?;
// Refresh toggle states
let app_handle = app.clone();
let grpc_clone = grpc.clone();
tauri::async_runtime::spawn(async move {
refresh_toggle_states(&app_handle, &grpc_clone).await;
});
// Start status polling
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
poll_status(app_handle, grpc).await;
});
Ok(())
}
async fn poll_status(app: AppHandle, grpc: GrpcClient) {
loop {
tokio::time::sleep(STATUS_POLL_INTERVAL).await;
let mut client = match grpc.get_client().await {
Ok(c) => c,
Err(e) => {
log::warn!("pollStatus: {}", e);
grpc.reset().await;
continue;
}
};
let resp = match client
.status(proto::StatusRequest {
get_full_peer_status: true,
should_run_probes: false,
wait_for_ready: None,
})
.await
{
Ok(r) => r.into_inner(),
Err(e) => {
log::warn!("pollStatus: status rpc: {}", e);
grpc.reset().await;
continue;
}
};
let mut info = StatusInfo {
status: resp.status.clone(),
ip: String::new(),
public_key: String::new(),
fqdn: String::new(),
connected_peers: 0,
};
if let Some(ref full) = resp.full_status {
if let Some(ref lp) = full.local_peer_state {
info.ip = lp.ip.clone();
info.public_key = lp.pub_key.clone();
info.fqdn = lp.fqdn.clone();
}
info.connected_peers = full.peers.len();
}
// Update tray label
let label = if info.ip.is_empty() {
format!("Status: {}", info.status)
} else {
format!("Status: {} ({})", info.status, info.ip)
};
// Update tray menu status label via stored reference
let menu_items = app.state::<SharedTrayMenuItems>();
if let Some(ref items) = *menu_items.lock().await {
let _ = items.status_item.set_text(&label);
}
// Update tray icon
if let Some(tray) = app.tray_by_id("main") {
let icon_bytes = icon_for_status(&info.status);
if let Ok(icon) = Image::from_bytes(icon_bytes) {
let _ = tray.set_icon(Some(icon));
}
}
// Emit status-changed event to frontend
let _ = app.emit("status-changed", &info);
}
}
async fn handle_toggle(app: &AppHandle, grpc: &GrpcClient, toggle_id: &str) {
let mut client = match grpc.get_client().await {
Ok(c) => c,
Err(e) => {
log::error!("toggle: get client: {}", e);
return;
}
};
// Get current config
let cfg = match client
.get_config(proto::GetConfigRequest {
profile_name: String::new(),
username: String::new(),
})
.await
{
Ok(r) => r.into_inner(),
Err(e) => {
log::error!("toggle: get config: {}", e);
return;
}
};
// Build set config request based on which toggle was clicked
let mut req = proto::SetConfigRequest {
username: String::new(),
profile_name: String::new(),
management_url: cfg.management_url,
admin_url: cfg.admin_url,
rosenpass_enabled: Some(cfg.rosenpass_enabled),
interface_name: Some(cfg.interface_name),
wireguard_port: Some(cfg.wireguard_port),
optional_pre_shared_key: Some(cfg.pre_shared_key),
disable_auto_connect: Some(cfg.disable_auto_connect),
server_ssh_allowed: Some(cfg.server_ssh_allowed),
rosenpass_permissive: Some(cfg.rosenpass_permissive),
disable_notifications: Some(cfg.disable_notifications),
lazy_connection_enabled: Some(cfg.lazy_connection_enabled),
block_inbound: Some(cfg.block_inbound),
network_monitor: None,
disable_client_routes: None,
disable_server_routes: None,
disable_dns: None,
disable_firewall: None,
block_lan_access: None,
nat_external_i_ps: vec![],
clean_nat_external_i_ps: false,
custom_dns_address: vec![],
extra_i_face_blacklist: vec![],
dns_labels: vec![],
clean_dns_labels: false,
dns_route_interval: None,
mtu: None,
enable_ssh_root: None,
enable_sshsftp: None,
enable_ssh_local_port_forwarding: None,
enable_ssh_remote_port_forwarding: None,
disable_ssh_auth: None,
ssh_jwt_cache_ttl: None,
};
match toggle_id {
"toggle_ssh" => req.server_ssh_allowed = Some(!cfg.server_ssh_allowed),
"toggle_auto_connect" => req.disable_auto_connect = Some(!cfg.disable_auto_connect),
"toggle_rosenpass" => req.rosenpass_enabled = Some(!cfg.rosenpass_enabled),
"toggle_lazy_conn" => req.lazy_connection_enabled = Some(!cfg.lazy_connection_enabled),
"toggle_block_inbound" => req.block_inbound = Some(!cfg.block_inbound),
"toggle_notifications" => req.disable_notifications = Some(!cfg.disable_notifications),
_ => return,
}
if let Err(e) = client.set_config(req).await {
log::error!("toggle {}: set config: {}", toggle_id, e);
}
// Refresh toggle states after change
refresh_toggle_states(app, grpc).await;
}
async fn refresh_toggle_states(app: &AppHandle, grpc: &GrpcClient) {
let mut client = match grpc.get_client().await {
Ok(c) => c,
Err(e) => {
log::debug!("refresh toggles: {}", e);
return;
}
};
let cfg = match client
.get_config(proto::GetConfigRequest {
profile_name: String::new(),
username: String::new(),
})
.await
{
Ok(r) => r.into_inner(),
Err(e) => {
log::debug!("refresh toggles: get config: {}", e);
return;
}
};
let menu_items = app.state::<SharedTrayMenuItems>();
let guard = menu_items.lock().await;
if let Some(ref items) = *guard {
let _ = items.ssh_item.set_checked(cfg.server_ssh_allowed);
let _ = items.auto_connect_item.set_checked(!cfg.disable_auto_connect);
let _ = items.rosenpass_item.set_checked(cfg.rosenpass_enabled);
let _ = items.lazy_conn_item.set_checked(cfg.lazy_connection_enabled);
let _ = items.block_inbound_item.set_checked(cfg.block_inbound);
let _ = items.notifications_item.set_checked(!cfg.disable_notifications);
}
}

View File

@@ -1,36 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
"productName": "NetBird",
"identifier": "io.netbird.client",
"version": "0.1.0",
"build": {
"frontendDist": "../frontend/dist",
"beforeBuildCommand": "cd ../frontend && npm run build"
},
"app": {
"windows": [
{
"title": "NetBird",
"width": 900,
"height": 650,
"visible": false,
"resizable": true,
"skipTaskbar": true
}
],
"trayIcon": {
"iconPath": "icons/netbird-systemtray-disconnected.png",
"iconAsTemplate": true
},
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/netbird.png"
]
}
}

View File

@@ -1,4 +0,0 @@
frontend/node_modules/
frontend/dist/
bin/
.task/

View File

@@ -1,32 +0,0 @@
version: '3'
includes:
common: ./build/Taskfile.yml
linux: ./build/linux/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
vars:
APP_NAME: "netbird-ui"
BIN_DIR: "bin"
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks:
build:
summary: Builds the application
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
cmds:
- task: "{{OS}}:package"
run:
summary: Runs the application
cmds:
- task: "{{OS}}:run"
dev:
summary: Runs the application in development mode
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Some files were not shown because too many files have changed in this diff Show More