Wails UI
3
client/uiwails/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
bin/
|
||||
1
client/uiwails/.task/checksum/build-frontend--DEV--
Normal file
@@ -0,0 +1 @@
|
||||
4340708917a9379498dcca2d1bd8c665
|
||||
@@ -0,0 +1 @@
|
||||
f88f16cee21f42c24ff6b3c1410b0ddd
|
||||
32
client/uiwails/Taskfile.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
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}}
|
||||
BIN
client/uiwails/assets/connected.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
client/uiwails/assets/disconnected.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
client/uiwails/assets/netbird-disconnected.ico
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
client/uiwails/assets/netbird-disconnected.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connected-dark.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connected-dark.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connected-macos.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connected.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connected.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connecting-dark.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connecting-dark.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connecting-macos.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connecting.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
client/uiwails/assets/netbird-systemtray-connecting.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
client/uiwails/assets/netbird-systemtray-disconnected-macos.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
client/uiwails/assets/netbird-systemtray-disconnected.ico
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
client/uiwails/assets/netbird-systemtray-disconnected.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
client/uiwails/assets/netbird-systemtray-error-dark.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
client/uiwails/assets/netbird-systemtray-error-dark.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
client/uiwails/assets/netbird-systemtray-error-macos.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
client/uiwails/assets/netbird-systemtray-error.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
client/uiwails/assets/netbird-systemtray-error.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
BIN
client/uiwails/assets/netbird-systemtray-update-connected.ico
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
client/uiwails/assets/netbird-systemtray-update-connected.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
BIN
client/uiwails/assets/netbird-systemtray-update-disconnected.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
client/uiwails/assets/netbird-systemtray-update-disconnected.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
client/uiwails/assets/netbird.ico
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
client/uiwails/assets/netbird.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
61
client/uiwails/build/Taskfile.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
go:mod:tidy:
|
||||
summary: Runs `go mod tidy`
|
||||
internal: true
|
||||
cmds:
|
||||
- go mod tidy
|
||||
|
||||
install:frontend:deps:
|
||||
summary: Install frontend dependencies
|
||||
dir: frontend
|
||||
sources:
|
||||
- package.json
|
||||
- package-lock.json
|
||||
generates:
|
||||
- node_modules
|
||||
preconditions:
|
||||
- sh: npm version
|
||||
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
|
||||
cmds:
|
||||
- npm install
|
||||
|
||||
build:frontend:
|
||||
label: build:frontend (DEV={{.DEV}})
|
||||
summary: Build the frontend project
|
||||
dir: frontend
|
||||
sources:
|
||||
- "**/*"
|
||||
- exclude: node_modules/**/*
|
||||
generates:
|
||||
- dist/**/*
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
cmds:
|
||||
- npm run {{.BUILD_COMMAND}} -q
|
||||
env:
|
||||
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
|
||||
vars:
|
||||
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
|
||||
|
||||
generate:icons:
|
||||
summary: Generates Windows `.ico` and Mac `.icns` from an image
|
||||
dir: build
|
||||
sources:
|
||||
- "appicon.png"
|
||||
generates:
|
||||
- "icons.icns"
|
||||
- "icon.ico"
|
||||
cmds:
|
||||
- echo "Icon generation skipped (no appicon.png)"
|
||||
status:
|
||||
- test ! -f appicon.png
|
||||
|
||||
dev:frontend:
|
||||
summary: Runs the frontend in development mode
|
||||
dir: frontend
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
cmds:
|
||||
- npm run dev -- --port {{.VITE_PORT}} --strictPort
|
||||
24
client/uiwails/build/build-ui-linux.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Build script for NetBird Wails v3 on Linux
|
||||
set -e
|
||||
|
||||
echo "Installing system dependencies for Wails v3 on Linux..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libayatana-appindicator3-dev \
|
||||
gcc \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libglib2.0-dev \
|
||||
libsoup-3.0-dev \
|
||||
libx11-dev \
|
||||
npm
|
||||
|
||||
echo "Installing wails3 CLI..."
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.72
|
||||
|
||||
echo "Building fancyui..."
|
||||
cd "$(dirname "$0")/.."
|
||||
wails3 build
|
||||
|
||||
echo "Build complete."
|
||||
35
client/uiwails/build/linux/Taskfile.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
version: '3'
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Builds the application for Linux
|
||||
cmds:
|
||||
- task: build:native
|
||||
vars:
|
||||
DEV: '{{.DEV}}'
|
||||
OUTPUT: '{{.OUTPUT}}'
|
||||
|
||||
build:native:
|
||||
summary: Builds the application natively on Linux
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
DEV:
|
||||
ref: .DEV
|
||||
cmds:
|
||||
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
|
||||
vars:
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
|
||||
run:
|
||||
cmds:
|
||||
- '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
217
client/uiwails/event/event.go
Normal file
@@ -0,0 +1,217 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// NotifyFunc is a callback used to send desktop notifications.
|
||||
type NotifyFunc func(title, body string)
|
||||
|
||||
// Handler is a callback invoked for each received daemon event.
|
||||
type Handler func(*proto.SystemEvent)
|
||||
|
||||
// Manager subscribes to daemon events and dispatches them.
|
||||
type Manager struct {
|
||||
addr string
|
||||
notify NotifyFunc
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
enabled bool
|
||||
handlers []Handler
|
||||
|
||||
connMu sync.Mutex
|
||||
conn *grpc.ClientConn
|
||||
client proto.DaemonServiceClient
|
||||
}
|
||||
|
||||
// NewManager creates a new event Manager.
|
||||
func NewManager(addr string, notify NotifyFunc) *Manager {
|
||||
return &Manager{
|
||||
addr: addr,
|
||||
notify: notify,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins event streaming with exponential backoff reconnection.
|
||||
func (m *Manager) Start(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
m.ctx, m.cancel = context.WithCancel(ctx)
|
||||
m.mu.Unlock()
|
||||
|
||||
expBackOff := backoff.WithContext(&backoff.ExponentialBackOff{
|
||||
InitialInterval: time.Second,
|
||||
RandomizationFactor: backoff.DefaultRandomizationFactor,
|
||||
Multiplier: backoff.DefaultMultiplier,
|
||||
MaxInterval: 10 * time.Second,
|
||||
MaxElapsedTime: 0,
|
||||
Stop: backoff.Stop,
|
||||
Clock: backoff.SystemClock,
|
||||
}, ctx)
|
||||
|
||||
if err := backoff.Retry(m.streamEvents, expBackOff); err != nil {
|
||||
log.Errorf("event stream ended: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) streamEvents() error {
|
||||
m.mu.Lock()
|
||||
ctx := m.ctx
|
||||
m.mu.Unlock()
|
||||
|
||||
client, err := m.getClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
stream, err := client.SubscribeEvents(ctx, &proto.SubscribeRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribe events: %w", err)
|
||||
}
|
||||
|
||||
log.Info("subscribed to daemon events")
|
||||
defer log.Info("unsubscribed from daemon events")
|
||||
|
||||
for {
|
||||
event, err := stream.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("receive event: %w", err)
|
||||
}
|
||||
m.handleEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop cancels the event stream and closes the connection.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.connMu.Lock()
|
||||
if m.conn != nil {
|
||||
m.conn.Close()
|
||||
m.conn = nil
|
||||
m.client = nil
|
||||
}
|
||||
m.connMu.Unlock()
|
||||
}
|
||||
|
||||
// SetNotificationsEnabled enables or disables desktop notifications.
|
||||
func (m *Manager) SetNotificationsEnabled(enabled bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.enabled = enabled
|
||||
}
|
||||
|
||||
// AddHandler registers an event handler.
|
||||
func (m *Manager) AddHandler(h Handler) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.handlers = append(m.handlers, h)
|
||||
}
|
||||
|
||||
func (m *Manager) handleEvent(event *proto.SystemEvent) {
|
||||
m.mu.Lock()
|
||||
enabled := m.enabled
|
||||
handlers := slices.Clone(m.handlers)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Critical events are always shown.
|
||||
if !enabled && event.Severity != proto.SystemEvent_CRITICAL {
|
||||
goto dispatch
|
||||
}
|
||||
|
||||
if event.UserMessage != "" && m.notify != nil {
|
||||
title := getEventTitle(event)
|
||||
body := event.UserMessage
|
||||
if id := event.Metadata["id"]; id != "" {
|
||||
body += fmt.Sprintf(" ID: %s", id)
|
||||
}
|
||||
m.notify(title, body)
|
||||
}
|
||||
|
||||
dispatch:
|
||||
for _, h := range handlers {
|
||||
go h(event)
|
||||
}
|
||||
}
|
||||
|
||||
func getEventTitle(event *proto.SystemEvent) string {
|
||||
var prefix string
|
||||
switch event.Severity {
|
||||
case proto.SystemEvent_CRITICAL:
|
||||
prefix = "Critical"
|
||||
case proto.SystemEvent_ERROR:
|
||||
prefix = "Error"
|
||||
case proto.SystemEvent_WARNING:
|
||||
prefix = "Warning"
|
||||
default:
|
||||
prefix = "Info"
|
||||
}
|
||||
|
||||
var category string
|
||||
switch event.Category {
|
||||
case proto.SystemEvent_DNS:
|
||||
category = "DNS"
|
||||
case proto.SystemEvent_NETWORK:
|
||||
category = "Network"
|
||||
case proto.SystemEvent_AUTHENTICATION:
|
||||
category = "Authentication"
|
||||
case proto.SystemEvent_CONNECTIVITY:
|
||||
category = "Connectivity"
|
||||
default:
|
||||
category = "System"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: %s", prefix, category)
|
||||
}
|
||||
|
||||
// getClient returns a cached gRPC client, creating the connection on first use.
|
||||
func (m *Manager) getClient() (proto.DaemonServiceClient, error) {
|
||||
m.connMu.Lock()
|
||||
defer m.connMu.Unlock()
|
||||
|
||||
if m.client != nil {
|
||||
return m.client, nil
|
||||
}
|
||||
|
||||
target := m.addr
|
||||
if strings.HasPrefix(target, "tcp://") {
|
||||
target = strings.TrimPrefix(target, "tcp://")
|
||||
} else if strings.HasPrefix(target, "unix://") {
|
||||
target = "unix:" + strings.TrimPrefix(target, "unix://")
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
target,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithUserAgent("netbird-fancyui/"+version.NetbirdVersion()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.conn = conn
|
||||
m.client = proto.NewDaemonServiceClient(conn)
|
||||
log.Debugf("event manager: gRPC connection established to %s", m.addr)
|
||||
|
||||
return m.client, nil
|
||||
}
|
||||
12
client/uiwails/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetBird</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2502
client/uiwails/frontend/package-lock.json
generated
Normal file
26
client/uiwails/frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "netbird-fancyui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wailsio/runtime": "latest",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
53
client/uiwails/frontend/src/App.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { HashRouter, Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
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 Go backend
|
||||
* and programmatically navigates the React router.
|
||||
*/
|
||||
function Navigator() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = Events.On('navigate', (event: { data: string[] }) => {
|
||||
const path = event.data[0]
|
||||
if (path) navigate(path)
|
||||
})
|
||||
return () => {
|
||||
if (typeof unsub === 'function') unsub()
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Navigator />
|
||||
<div className="min-h-screen bg-nb-gray-DEFAULT text-nb-gray-100 flex">
|
||||
<NavBar />
|
||||
<main className="flex-1 p-6 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>
|
||||
)
|
||||
}
|
||||
126
client/uiwails/frontend/src/bindings.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Type definitions for the auto-generated Wails v3 service bindings.
|
||||
* Run `wails3 generate bindings` to regenerate the actual TypeScript bindings
|
||||
* from the Go service methods. These types mirror the Go structs.
|
||||
*
|
||||
* The actual binding files will be generated into frontend/bindings/ by the
|
||||
* Wails CLI. This file serves as a centralized re-export and type reference.
|
||||
*/
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Wails v3 service call helper.
|
||||
* After running `wails3 generate bindings`, use the generated functions directly.
|
||||
* This helper wraps window.__wails.call for manual use during development.
|
||||
*/
|
||||
export async function call<T>(service: string, method: string, ...args: unknown[]): Promise<T> {
|
||||
// This will be replaced by generated bindings after `wails3 generate bindings`
|
||||
// For now, call via the Wails runtime bridge
|
||||
const w = window as typeof window & {
|
||||
go?: {
|
||||
[svc: string]: {
|
||||
[method: string]: (...args: unknown[]) => Promise<T>
|
||||
}
|
||||
}
|
||||
}
|
||||
const svc = w.go?.[service]
|
||||
if (!svc) throw new Error(`Service ${service} not found. Run wails3 generate bindings.`)
|
||||
const fn = svc[method]
|
||||
if (!fn) throw new Error(`Method ${service}.${method} not found.`)
|
||||
return fn(...args)
|
||||
}
|
||||
132
client/uiwails/frontend/src/components/NavBar.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import NetBirdLogo from './NetBirdLogo'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Status', icon: StatusIcon },
|
||||
{ to: '/peers', label: 'Peers', icon: PeersIcon },
|
||||
{ to: '/networks', label: 'Networks', icon: NetworksIcon },
|
||||
{ to: '/profiles', label: 'Profiles', icon: ProfilesIcon },
|
||||
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ to: '/debug', label: 'Debug', icon: DebugIcon },
|
||||
{ to: '/update', label: 'Update', icon: UpdateIcon },
|
||||
]
|
||||
|
||||
export default function NavBar() {
|
||||
return (
|
||||
<nav className="w-[15rem] min-w-[15rem] bg-nb-gray-950 border-r border-nb-gray-900 flex flex-col h-screen">
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-5 border-b border-nb-gray-900">
|
||||
<NetBirdLogo full />
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-[.87rem] font-normal transition-colors ${
|
||||
isActive
|
||||
? 'bg-nb-gray-900 text-white'
|
||||
: 'text-nb-gray-400 hover:text-white hover:bg-nb-gray-900/50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon active={isActive} />
|
||||
<span>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Version footer */}
|
||||
<div className="px-5 py-3 border-t border-nb-gray-900 text-xs text-nb-gray-500">
|
||||
NetBird Client
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} 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-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} 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-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} 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-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} 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-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} 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-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} 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-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} 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>
|
||||
)
|
||||
}
|
||||
20
client/uiwails/frontend/src/components/NetBirdLogo.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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 text-nb-gray-100">NETBIRD</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
client/uiwails/frontend/src/index.css
Normal file
@@ -0,0 +1,113 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@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;
|
||||
|
||||
--color-nb-gray-DEFAULT: #181a1d;
|
||||
--color-nb-gray-50: #f4f6f7;
|
||||
--color-nb-gray-100: #e4e7e9;
|
||||
--color-nb-gray-200: #cbd2d6;
|
||||
--color-nb-gray-300: #a3adb5;
|
||||
--color-nb-gray-400: #7c8994;
|
||||
--color-nb-gray-500: #616e79;
|
||||
--color-nb-gray-600: #535d67;
|
||||
--color-nb-gray-700: #474e57;
|
||||
--color-nb-gray-800: #3f444b;
|
||||
--color-nb-gray-900: #2e3238;
|
||||
--color-nb-gray-910: #292c31;
|
||||
--color-nb-gray-920: #25282d;
|
||||
--color-nb-gray-930: #212428;
|
||||
--color-nb-gray-940: #1c1e21;
|
||||
--color-nb-gray-950: #181a1d;
|
||||
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background-color: #181a1d;
|
||||
color: #e4e7e9;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Toggle switch — matches dashboard ToggleSwitch.tsx */
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
background-color: var(--color-nb-gray-700);
|
||||
}
|
||||
.toggle-track[aria-checked="true"] {
|
||||
background-color: var(--color-netbird-400);
|
||||
}
|
||||
.toggle-thumb {
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background-color: white;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transition: transform 0.2s;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toggle-track[aria-checked="true"] .toggle-thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Small toggle (matches dashboard ToggleSwitch size=small) */
|
||||
.toggle-track-sm {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
background-color: var(--color-nb-gray-700);
|
||||
}
|
||||
.toggle-track-sm[aria-checked="true"] {
|
||||
background-color: var(--color-netbird-400);
|
||||
}
|
||||
.toggle-thumb-sm {
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
background-color: white;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transition: transform 0.2s;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toggle-track-sm[aria-checked="true"] .toggle-thumb-sm {
|
||||
transform: translateX(17px);
|
||||
}
|
||||
10
client/uiwails/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
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>,
|
||||
)
|
||||
169
client/uiwails/frontend/src/pages/Debug.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { DebugBundleParams, DebugBundleResult } from '../bindings'
|
||||
|
||||
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)…` : 'Creating debug bundle…')
|
||||
|
||||
const params: DebugBundleParams = {
|
||||
anonymize,
|
||||
systemInfo,
|
||||
upload,
|
||||
uploadUrl: upload ? uploadUrl : '',
|
||||
runDurationMins: runForDuration ? durationMins : 0,
|
||||
enablePersistence: true,
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Debug] calling services.DebugService.CreateDebugBundle')
|
||||
const res = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.DebugService.CreateDebugBundle', params) as DebugBundleResult
|
||||
console.log('[Debug] CreateDebugBundle result:', JSON.stringify(res))
|
||||
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-2xl font-bold mb-2">Debug</h1>
|
||||
<p className="text-nb-gray-400 text-sm mb-6">
|
||||
Create a debug bundle to help troubleshoot issues with NetBird.
|
||||
</p>
|
||||
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 space-y-5">
|
||||
<Toggle label="Anonymize sensitive information (IPs, domains…)" checked={anonymize} onChange={setAnonymize} />
|
||||
<Toggle label="Include system information (routes, interfaces…)" checked={systemInfo} onChange={setSystemInfo} />
|
||||
<Toggle label="Upload bundle automatically after creation" checked={upload} onChange={setUpload} />
|
||||
|
||||
{upload && (
|
||||
<div>
|
||||
<label className="block text-sm text-nb-gray-400 mb-1.5">Upload URL</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 bg-nb-gray-900 border border-nb-gray-800 rounded-lg text-sm focus:outline-none focus:border-netbird"
|
||||
value={uploadUrl}
|
||||
onChange={e => setUploadUrl(e.target.value)}
|
||||
disabled={running}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-nb-gray-900 pt-4">
|
||||
<Toggle
|
||||
label="Run with trace logs before creating bundle"
|
||||
checked={runForDuration}
|
||||
onChange={setRunForDuration}
|
||||
/>
|
||||
{runForDuration && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-nb-gray-400">for</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={durationMins}
|
||||
onChange={e => setDurationMins(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
disabled={running}
|
||||
className="w-16 px-2 py-1 bg-nb-gray-900 border border-nb-gray-800 rounded text-sm text-center focus:outline-none focus:border-netbird"
|
||||
/>
|
||||
<span className="text-sm text-nb-gray-400">{durationMins === 1 ? 'minute' : 'minutes'}</span>
|
||||
</div>
|
||||
)}
|
||||
{runForDuration && (
|
||||
<p className="text-xs text-nb-gray-500 mt-2">
|
||||
Note: NetBird will be brought up and down during collection.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div className="mt-4 p-3 bg-nb-gray-920 border border-nb-gray-900 rounded-lg text-sm">
|
||||
<span className={running ? 'animate-pulse text-yellow-300' : 'text-green-400'}>{progress}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="mt-4 bg-nb-gray-920 rounded-xl p-5 border border-nb-gray-900 text-sm space-y-2">
|
||||
{result.uploadedKey ? (
|
||||
<>
|
||||
<p className="text-green-400 font-medium">Bundle uploaded successfully!</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-nb-gray-400">Upload key:</span>
|
||||
<code className="bg-nb-gray-900 px-2 py-0.5 rounded text-xs font-mono">{result.uploadedKey}</code>
|
||||
</div>
|
||||
</>
|
||||
) : result.uploadFailureReason ? (
|
||||
<p className="text-yellow-400">Upload failed: {result.uploadFailureReason}</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-nb-gray-400">Local path:</span>
|
||||
<code className="bg-nb-gray-900 px-2 py-0.5 rounded text-xs font-mono break-all">{result.localPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={running}
|
||||
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{running ? 'Running…' : 'Create Debug Bundle'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className="toggle-track"
|
||||
>
|
||||
<span className="toggle-thumb" />
|
||||
</button>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
356
client/uiwails/frontend/src/pages/Networks.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { NetworkInfo } from '../bindings'
|
||||
|
||||
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.NetworkService'
|
||||
|
||||
type Tab = 'all' | 'overlapping' | 'exit-node'
|
||||
type SortKey = 'id' | 'range'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
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 method: string
|
||||
if (tab === 'all') method = 'ListNetworks'
|
||||
else if (tab === 'overlapping') method = 'ListOverlappingNetworks'
|
||||
else method = 'ListExitNodes'
|
||||
const data = await Call.ByName(`${SVC}.${method}`) as NetworkInfo[]
|
||||
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 Call.ByName(`${SVC}.DeselectNetwork`, id)
|
||||
else await Call.ByName(`${SVC}.SelectNetwork`, id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function selectAll() {
|
||||
try {
|
||||
await Call.ByName(`${SVC}.SelectAllNetworks`)
|
||||
await load()
|
||||
} catch (e) { setError(String(e)) }
|
||||
}
|
||||
|
||||
async function deselectAll() {
|
||||
try {
|
||||
await Call.ByName(`${SVC}.DeselectAllNetworks`)
|
||||
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-2xl font-bold mb-6">Networks</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-5 bg-nb-gray-920 p-1 rounded-lg border border-nb-gray-900 max-w-md">
|
||||
{([['all', 'All Networks'], ['overlapping', 'Overlapping'], ['exit-node', 'Exit Nodes']] as [Tab, string][]).map(([t, label]) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
tab === t ? 'bg-netbird text-white' : 'text-nb-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar: search + actions */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-nb-gray-500" 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 pl-9 pr-3 py-2 bg-nb-gray-920 border border-nb-gray-800/40 rounded-md text-sm text-nb-gray-100 placeholder-nb-gray-500 focus:outline-none focus:border-nb-gray-600 transition-colors"
|
||||
placeholder="Search by name, range or domain..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<ActionButton onClick={selectAll}>Select All</ActionButton>
|
||||
<ActionButton onClick={deselectAll}>Deselect All</ActionButton>
|
||||
<ActionButton onClick={load}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-950/40 border border-red-500 rounded-md text-red-400 text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection summary */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="mb-3 text-xs text-nb-gray-400">
|
||||
{selectedCount} of {networks.length} network{networks.length !== 1 ? 's' : ''} selected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{loading && networks.length === 0 ? (
|
||||
<TableSkeleton />
|
||||
) : filtered.length === 0 && networks.length === 0 ? (
|
||||
<EmptyState tab={tab} />
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-12 text-center text-nb-gray-400 text-sm">
|
||||
No networks match your search.
|
||||
<button onClick={() => setSearch('')} className="ml-2 text-netbird hover:underline">Clear search</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-nb-gray-900 border-b border-nb-gray-900/60">
|
||||
<SortableHeader label="Network" sortKey="id" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortableHeader label="Range / Domains" sortKey="range" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Resolved IPs</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide w-20">Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(n => (
|
||||
<NetworkRow key={n.id} network={n} onToggle={() => toggle(n.id, n.selected)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination footer */}
|
||||
<div className="px-4 py-2.5 bg-nb-gray-940 border-t border-nb-gray-900/60 text-xs text-nb-gray-400">
|
||||
Showing {filtered.length} of {networks.length} network{networks.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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 (
|
||||
<tr className="border-b border-nb-gray-900/40 hover:bg-nb-gray-940 transition-colors group/row">
|
||||
{/* Network name cell (dashboard-style icon square + name) */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<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-sm text-nb-gray-100">{network.id}</span>
|
||||
{hasDomains && domains.length > 1 && (
|
||||
<span className="text-xs text-nb-gray-500 mt-0.5">{domains.length} domains</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Range / Domains */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
{hasDomains ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{domains.slice(0, 2).map(d => (
|
||||
<span key={d} className="font-mono text-[0.82rem] text-nb-gray-300">{d}</span>
|
||||
))}
|
||||
{domains.length > 2 && (
|
||||
<span className="text-xs text-nb-gray-500" title={domains.join(', ')}>+{domains.length - 2} more</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-mono text-[0.82rem] text-nb-gray-300">{network.range}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Resolved IPs */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
{resolvedEntries.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{resolvedEntries.slice(0, 2).map(([domain, ips]) => (
|
||||
<span key={domain} className="font-mono text-xs text-nb-gray-400" title={`${domain}: ${ips.join(', ')}`}>
|
||||
{ips[0]}{ips.length > 1 && <span className="text-nb-gray-600"> +{ips.length - 1}</span>}
|
||||
</span>
|
||||
))}
|
||||
{resolvedEntries.length > 2 && (
|
||||
<span className="text-xs text-nb-gray-600">+{resolvedEntries.length - 2} more</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-nb-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Active toggle */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={network.selected}
|
||||
onClick={onToggle}
|
||||
className="toggle-track-sm"
|
||||
>
|
||||
<span className="toggle-thumb-sm" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Network Icon Square (matches dashboard NetworkInformationSquare) ---- */
|
||||
|
||||
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-md bg-nb-gray-800 flex items-center justify-center text-sm font-medium text-nb-gray-100 uppercase">
|
||||
{initials}
|
||||
{/* Status dot */}
|
||||
<div className={`absolute bottom-0 right-0 h-2 w-2 rounded-full z-10 ${active ? 'bg-green-500' : 'bg-nb-gray-700'}`} />
|
||||
{/* Corner mask for rounded dot cutout */}
|
||||
<div className="absolute bottom-0 right-0 h-3 w-3 bg-nb-gray-950 rounded-tl-[8px] rounded-br group-hover/row:bg-nb-gray-940 transition-colors" />
|
||||
</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 (
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide cursor-pointer select-none hover:text-nb-gray-300 transition-colors"
|
||||
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>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Action Button ---- */
|
||||
|
||||
function ActionButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-nb-gray-920 border border-nb-gray-800/40 text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- 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-md border border-nb-gray-900/60 bg-nb-gray-920 py-16 flex flex-col items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-lg bg-nb-gray-800 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-nb-gray-400" 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-sm text-nb-gray-400">{msg}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Loading Skeleton ---- */
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
|
||||
<div className="bg-nb-gray-900 h-11" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 px-4 py-4 border-b border-nb-gray-900/40 animate-pulse">
|
||||
<div className="w-4 h-4 rounded bg-nb-gray-800" />
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-md bg-nb-gray-800" />
|
||||
<div className="h-4 w-24 rounded bg-nb-gray-800" />
|
||||
</div>
|
||||
<div className="h-4 w-32 rounded bg-nb-gray-800" />
|
||||
<div className="h-4 w-20 rounded bg-nb-gray-800" />
|
||||
<div className="h-6 w-16 rounded-md bg-nb-gray-800" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
354
client/uiwails/frontend/src/pages/Peers.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { PeerInfo } from '../bindings'
|
||||
|
||||
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.PeersService'
|
||||
|
||||
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 '—'
|
||||
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 Call.ByName(`${SVC}.GetPeers`) as PeerInfo[]
|
||||
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-2xl font-bold mb-6">Peers</h1>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-nb-gray-500" 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 pl-9 pr-3 py-2 bg-nb-gray-920 border border-nb-gray-800/40 rounded-md text-sm text-nb-gray-100 placeholder-nb-gray-500 focus:outline-none focus:border-nb-gray-600 transition-colors"
|
||||
placeholder="Search by name, IP or status..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<ActionButton onClick={load}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-950/40 border border-red-500 rounded-md text-red-400 text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{peers.length > 0 && (
|
||||
<div className="mb-3 text-xs text-nb-gray-400">
|
||||
{connectedCount} of {peers.length} peer{peers.length !== 1 ? 's' : ''} connected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{loading && peers.length === 0 ? (
|
||||
<TableSkeleton />
|
||||
) : peers.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-12 text-center text-nb-gray-400 text-sm">
|
||||
No peers match your search.
|
||||
<button onClick={() => setSearch('')} className="ml-2 text-netbird hover:underline">Clear search</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-nb-gray-900 border-b border-nb-gray-900/60">
|
||||
<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} />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Connection</th>
|
||||
<SortableHeader label="Latency" sortKey="latency" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Transfer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(p => (
|
||||
<PeerRow key={p.pubKey} peer={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="px-4 py-2.5 bg-nb-gray-940 border-t border-nb-gray-900/60 text-xs text-nb-gray-400">
|
||||
Showing {filtered.length} of {peers.length} peer{peers.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Row ---- */
|
||||
|
||||
function PeerRow({ peer }: { peer: PeerInfo }) {
|
||||
const name = peerName(peer)
|
||||
const connected = peer.connStatus === 'Connected'
|
||||
|
||||
return (
|
||||
<tr className="border-b border-nb-gray-900/40 hover:bg-nb-gray-940 transition-colors group/row">
|
||||
{/* Peer name */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<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-sm text-nb-gray-100 truncate max-w-[200px]" title={peer.fqdn}>{name}</span>
|
||||
{peer.networks && peer.networks.length > 0 && (
|
||||
<span className="text-xs text-nb-gray-500 mt-0.5">{peer.networks.length} network{peer.networks.length !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* IP */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<span className="font-mono text-[0.82rem] text-nb-gray-300">{peer.ip || '—'}</span>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<StatusBadge status={peer.connStatus} />
|
||||
</td>
|
||||
|
||||
{/* Connection type */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{connected ? (
|
||||
<>
|
||||
<span className="text-xs text-nb-gray-300">
|
||||
{peer.relayed ? 'Relayed' : 'Direct'}{' '}
|
||||
{peer.rosenpassEnabled && (
|
||||
<span className="text-green-400" title="Rosenpass post-quantum security enabled">PQ</span>
|
||||
)}
|
||||
</span>
|
||||
{peer.relayed && peer.relayAddress && (
|
||||
<span className="text-xs text-nb-gray-500 font-mono" title={peer.relayAddress}>
|
||||
via {peer.relayAddress.length > 24 ? peer.relayAddress.substring(0, 24) + '...' : peer.relayAddress}
|
||||
</span>
|
||||
)}
|
||||
{!peer.relayed && peer.localIceType && (
|
||||
<span className="text-xs text-nb-gray-500">{peer.localIceType} / {peer.remoteIceType}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-nb-gray-600">—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Latency */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<span className={`text-sm ${peer.latencyMs > 0 ? 'text-nb-gray-300' : 'text-nb-gray-600'}`}>
|
||||
{formatLatency(peer.latencyMs)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Transfer */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
{(peer.bytesRx > 0 || peer.bytesTx > 0) ? (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
<span className="text-nb-gray-400">
|
||||
<span className="text-green-400/70" title="Received">↓</span> {formatBytes(peer.bytesRx)}
|
||||
</span>
|
||||
<span className="text-nb-gray-400">
|
||||
<span className="text-blue-400/70" title="Sent">↑</span> {formatBytes(peer.bytesTx)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-nb-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- 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-md bg-nb-gray-800 flex items-center justify-center text-sm font-medium text-nb-gray-100 uppercase">
|
||||
{initials}
|
||||
<div className={`absolute bottom-0 right-0 h-2 w-2 rounded-full z-10 ${connected ? 'bg-green-500' : 'bg-nb-gray-700'}`} />
|
||||
<div className="absolute bottom-0 right-0 h-3 w-3 bg-nb-gray-950 rounded-tl-[8px] rounded-br group-hover/row:bg-nb-gray-940 transition-colors" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Status Badge ---- */
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const connected = status === 'Connected'
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
connected
|
||||
? 'bg-green-500/10 text-green-400 border border-green-500/20'
|
||||
: 'bg-nb-gray-800/50 text-nb-gray-400 border border-nb-gray-800'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-nb-gray-600'}`} />
|
||||
{status || 'Unknown'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- 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 (
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide cursor-pointer select-none hover:text-nb-gray-300 transition-colors"
|
||||
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>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Action Button ---- */
|
||||
|
||||
function ActionButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-nb-gray-920 border border-nb-gray-800/40 text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Empty State ---- */
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="rounded-md border border-nb-gray-900/60 bg-nb-gray-920 py-16 flex flex-col items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-lg bg-nb-gray-800 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-nb-gray-400" 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-sm text-nb-gray-400">No peers found. Connect to a network to see peers.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Loading Skeleton ---- */
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
|
||||
<div className="bg-nb-gray-900 h-11" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 px-4 py-4 border-b border-nb-gray-900/40 animate-pulse">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-md bg-nb-gray-800" />
|
||||
<div className="h-4 w-28 rounded bg-nb-gray-800" />
|
||||
</div>
|
||||
<div className="h-4 w-24 rounded bg-nb-gray-800" />
|
||||
<div className="h-5 w-20 rounded-full bg-nb-gray-800" />
|
||||
<div className="h-4 w-16 rounded bg-nb-gray-800" />
|
||||
<div className="h-4 w-14 rounded bg-nb-gray-800" />
|
||||
<div className="h-4 w-16 rounded bg-nb-gray-800" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
client/uiwails/frontend/src/pages/Profiles.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { ProfileInfo } from '../bindings'
|
||||
|
||||
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 {
|
||||
console.log('[Profiles] calling services.ProfileService.ListProfiles')
|
||||
const data = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.ListProfiles') as ProfileInfo[]
|
||||
console.log('[Profiles] ListProfiles returned', data?.length ?? 0, '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 Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.SwitchProfile', confirm.profile)
|
||||
else if (confirm.action === 'remove') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.RemoveProfile', confirm.profile)
|
||||
else if (confirm.action === 'logout') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.Logout', 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 Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.AddProfile', newName.trim())
|
||||
showInfo(`Profile '${newName.trim()}' created`)
|
||||
setNewName('')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">Profiles</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && (
|
||||
<div className="mb-4 p-3 bg-green-900/50 border border-green-700 rounded-lg text-green-300 text-sm">
|
||||
{info}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 max-w-sm w-full mx-4 border border-nb-gray-900">
|
||||
<h2 className="text-lg font-semibold mb-2 capitalize">
|
||||
{confirm.action === 'switch' ? 'Switch Profile' : confirm.action === 'remove' ? 'Remove Profile' : 'Deregister Profile'}
|
||||
</h2>
|
||||
<p className="text-nb-gray-300 text-sm mb-5">
|
||||
{confirm.action === 'switch' && `Switch to profile '${confirm.profile}'?`}
|
||||
{confirm.action === 'remove' && `Delete profile '${confirm.profile}'? This cannot be undone.`}
|
||||
{confirm.action === 'logout' && `Deregister from '${confirm.profile}'?`}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setConfirm(null)} className="px-4 py-2 text-sm bg-nb-gray-900 hover:bg-nb-gray-800 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleConfirm} disabled={loading} className="px-4 py-2 text-sm bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile list */}
|
||||
<div className="bg-nb-gray-920 rounded-xl border border-nb-gray-900 overflow-hidden mb-6">
|
||||
{profiles.length === 0 ? (
|
||||
<div className="p-4 text-nb-gray-400 text-sm">No profiles found.</div>
|
||||
) : (
|
||||
profiles.map(p => (
|
||||
<div key={p.name} className="flex items-center gap-3 px-4 py-3 border-b border-nb-gray-900 last:border-0">
|
||||
<span className="text-green-400 w-5 text-center">
|
||||
{p.isActive ? '✓' : ''}
|
||||
</span>
|
||||
<span className="flex-1 font-medium">{p.name}</span>
|
||||
{p.isActive && <span className="text-xs text-netbird px-2 py-0.5 bg-netbird-950/40 rounded-full">Active</span>}
|
||||
<div className="flex gap-2">
|
||||
{!p.isActive && (
|
||||
<button
|
||||
onClick={() => setConfirm({ action: 'switch', profile: p.name })}
|
||||
className="px-3 py-1 text-xs bg-netbird-600 hover:bg-netbird-500 rounded transition-colors"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirm({ action: 'logout', profile: p.name })}
|
||||
className="px-3 py-1 text-xs bg-nb-gray-900 hover:bg-nb-gray-800 rounded transition-colors"
|
||||
>
|
||||
Deregister
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirm({ action: 'remove', profile: p.name })}
|
||||
className="px-3 py-1 text-xs bg-red-900 hover:bg-red-800 rounded transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add new profile */}
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="flex-1 px-3 py-2 bg-nb-gray-920 border border-nb-gray-800 rounded-lg text-sm focus:outline-none focus:border-netbird"
|
||||
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}
|
||||
className="px-4 py-2 text-sm bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
Add Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
212
client/uiwails/frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { ConfigInfo } from '../bindings'
|
||||
|
||||
async function getConfig(): Promise<ConfigInfo | null> {
|
||||
try {
|
||||
console.log('[Settings] calling services.SettingsService.GetConfig')
|
||||
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.SettingsService.GetConfig')
|
||||
console.log('[Settings] GetConfig result:', JSON.stringify(result))
|
||||
return result as ConfigInfo
|
||||
} catch (e) {
|
||||
console.error('[Settings] GetConfig error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function setConfig(cfg: ConfigInfo): Promise<void> {
|
||||
console.log('[Settings] calling services.SettingsService.SetConfig')
|
||||
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.SettingsService.SetConfig', cfg)
|
||||
}
|
||||
|
||||
type Tab = 'connection' | 'network' | '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 className="text-nb-gray-400">Loading settings…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 bg-nb-gray-920 p-1 rounded-lg border border-nb-gray-900">
|
||||
{(['connection', 'network', 'security'] as Tab[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-2 rounded text-sm font-medium capitalize transition-colors ${
|
||||
tab === t ? 'bg-netbird text-white' : 'text-nb-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 space-y-5">
|
||||
|
||||
{tab === 'connection' && (
|
||||
<>
|
||||
<Field label="Management URL">
|
||||
<input
|
||||
className="input"
|
||||
value={config.managementUrl}
|
||||
onChange={e => update('managementUrl', e.target.value)}
|
||||
placeholder="https://api.netbird.io:443"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Admin URL">
|
||||
<input
|
||||
className="input"
|
||||
value={config.adminUrl}
|
||||
onChange={e => update('adminUrl', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Pre-shared Key">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={config.preSharedKey}
|
||||
onChange={e => update('preSharedKey', e.target.value)}
|
||||
placeholder="Leave empty to clear"
|
||||
/>
|
||||
</Field>
|
||||
<Toggle
|
||||
label="Connect automatically when service starts"
|
||||
checked={!config.disableAutoConnect}
|
||||
onChange={v => update('disableAutoConnect', !v)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Enable notifications"
|
||||
checked={!config.disableNotifications}
|
||||
onChange={v => update('disableNotifications', !v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'network' && (
|
||||
<>
|
||||
<Field label="Interface Name">
|
||||
<input
|
||||
className="input"
|
||||
value={config.interfaceName}
|
||||
onChange={e => update('interfaceName', e.target.value)}
|
||||
placeholder="netbird0"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="WireGuard Port">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={config.wireguardPort}
|
||||
onChange={e => update('wireguardPort', parseInt(e.target.value) || 0)}
|
||||
placeholder="51820"
|
||||
/>
|
||||
</Field>
|
||||
<Toggle
|
||||
label="Enable lazy connections (experimental)"
|
||||
checked={config.lazyConnectionEnabled}
|
||||
onChange={v => update('lazyConnectionEnabled', v)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Block inbound connections"
|
||||
checked={config.blockInbound}
|
||||
onChange={v => update('blockInbound', v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'security' && (
|
||||
<>
|
||||
<Toggle
|
||||
label="Allow SSH connections"
|
||||
checked={config.serverSshAllowed}
|
||||
onChange={v => update('serverSshAllowed', v)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Enable post-quantum security via Rosenpass"
|
||||
checked={config.rosenpassEnabled}
|
||||
onChange={v => update('rosenpassEnabled', v)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Rosenpass permissive mode"
|
||||
checked={config.rosenpassPermissive}
|
||||
onChange={v => update('rosenpassPermissive', v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{saved && <span className="text-green-400 text-sm">Saved!</span>}
|
||||
{error && <span className="text-red-400 text-sm">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-nb-gray-400 mb-1.5">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className="toggle-track"
|
||||
>
|
||||
<span className="toggle-thumb" />
|
||||
</button>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
164
client/uiwails/frontend/src/pages/Status.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Events, Call } from '@wailsio/runtime'
|
||||
import type { StatusInfo } from '../bindings'
|
||||
|
||||
async function getStatus(): Promise<StatusInfo | null> {
|
||||
try {
|
||||
console.log('[Dashboard] calling services.ConnectionService.GetStatus')
|
||||
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.GetStatus')
|
||||
console.log('[Dashboard] GetStatus result:', JSON.stringify(result))
|
||||
return result as StatusInfo
|
||||
} catch (e) {
|
||||
console.error('[Dashboard] GetStatus error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
console.log('[Dashboard] calling services.ConnectionService.Connect')
|
||||
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Connect')
|
||||
}
|
||||
|
||||
async function disconnect(): Promise<void> {
|
||||
console.log('[Dashboard] calling services.ConnectionService.Disconnect')
|
||||
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Disconnect')
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'Connected': return 'text-green-400'
|
||||
case 'Connecting': return 'text-yellow-400'
|
||||
case 'Disconnected': return 'text-nb-gray-400'
|
||||
default: return 'text-red-400'
|
||||
}
|
||||
}
|
||||
|
||||
function statusDot(status: string): string {
|
||||
switch (status) {
|
||||
case 'Connected': return 'bg-green-400'
|
||||
case 'Connecting': return 'bg-yellow-400 animate-pulse'
|
||||
case 'Disconnected': return 'bg-nb-gray-600'
|
||||
default: return 'bg-red-400'
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
// Poll every 10 seconds as fallback (push events handle real-time updates)
|
||||
const id = setInterval(refresh, 10000)
|
||||
// Also listen for push events from the tray
|
||||
const unsub = Events.On('status-changed', (event: { data: StatusInfo[] }) => {
|
||||
if (event.data[0]) setStatus(event.data[0])
|
||||
})
|
||||
return () => {
|
||||
clearInterval(id)
|
||||
if (typeof unsub === 'function') unsub()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
async function handleConnect() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await connect()
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await 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-2xl font-bold mb-6">Status</h1>
|
||||
|
||||
{/* Status card */}
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 mb-6 border border-nb-gray-900">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className={`w-3 h-3 rounded-full ${status ? statusDot(status.status) : 'bg-nb-gray-600'}`} />
|
||||
<span className={`text-xl font-semibold ${status ? statusColor(status.status) : 'text-nb-gray-400'}`}>
|
||||
{status?.status ?? 'Loading…'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{status.ip && (
|
||||
<>
|
||||
<span className="text-nb-gray-400">IP Address</span>
|
||||
<span className="font-mono">{status.ip}</span>
|
||||
</>
|
||||
)}
|
||||
{status.fqdn && (
|
||||
<>
|
||||
<span className="text-nb-gray-400">Hostname</span>
|
||||
<span className="font-mono">{status.fqdn}</span>
|
||||
</>
|
||||
)}
|
||||
{status.connectedPeers > 0 && (
|
||||
<>
|
||||
<span className="text-nb-gray-400">Connected Peers</span>
|
||||
<span>{status.connectedPeers}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="flex gap-3">
|
||||
{!isConnected && !isConnecting && (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={busy}
|
||||
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{busy ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
)}
|
||||
{(isConnected || isConnecting) && (
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
disabled={busy}
|
||||
className="px-6 py-2.5 bg-nb-gray-800 hover:bg-nb-gray-600 disabled:opacity-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{busy ? 'Disconnecting…' : 'Disconnect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
client/uiwails/frontend/src/pages/Update.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { InstallerResult } from '../bindings'
|
||||
|
||||
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)
|
||||
|
||||
// Animate dots when polling
|
||||
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 {
|
||||
console.log('[Update] calling services.UpdateService.TriggerUpdate')
|
||||
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.TriggerUpdate')
|
||||
} catch (e) {
|
||||
console.error('[Update] TriggerUpdate error:', e)
|
||||
setErrorMsg(String(e))
|
||||
setState('failed')
|
||||
return
|
||||
}
|
||||
|
||||
setState('polling')
|
||||
|
||||
// Poll for installer result (up to 15 minutes handled server-side)
|
||||
try {
|
||||
console.log('[Update] calling services.UpdateService.GetInstallerResult')
|
||||
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.GetInstallerResult') as InstallerResult
|
||||
console.log('[Update] GetInstallerResult:', JSON.stringify(result))
|
||||
if (result?.success) {
|
||||
setState('success')
|
||||
} else {
|
||||
setErrorMsg(result?.errorMsg ?? 'Update failed')
|
||||
setState('failed')
|
||||
}
|
||||
} catch (e) {
|
||||
// If the daemon restarts, the gRPC call may fail — treat as success
|
||||
setState('success')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-2">Update</h1>
|
||||
<p className="text-nb-gray-400 text-sm mb-8">
|
||||
Trigger an automatic client update managed by the NetBird daemon.
|
||||
</p>
|
||||
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 text-center">
|
||||
{state === 'idle' && (
|
||||
<>
|
||||
<p className="text-nb-gray-300 mb-5">Click below to trigger a daemon-managed update.</p>
|
||||
<button
|
||||
onClick={handleTriggerUpdate}
|
||||
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Trigger Update
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'triggering' && (
|
||||
<p className="text-yellow-300 animate-pulse">Triggering update…</p>
|
||||
)}
|
||||
|
||||
{state === 'polling' && (
|
||||
<div>
|
||||
<p className="text-yellow-300 text-lg mb-2">Updating{dots}</p>
|
||||
<p className="text-nb-gray-400 text-sm">The daemon is installing the update. Please wait.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'success' && (
|
||||
<div>
|
||||
<p className="text-green-400 text-lg font-semibold mb-2">Update Successful!</p>
|
||||
<p className="text-nb-gray-300 text-sm">The client has been updated. You may need to restart.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'failed' && (
|
||||
<div>
|
||||
<p className="text-red-400 text-lg font-semibold mb-2">Update Failed</p>
|
||||
{errorMsg && <p className="text-nb-gray-300 text-sm mb-4">{errorMsg}</p>}
|
||||
<button
|
||||
onClick={() => { setState('idle'); setErrorMsg('') }}
|
||||
className="px-4 py-2 text-sm bg-nb-gray-900 hover:bg-nb-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
client/uiwails/frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
15
client/uiwails/frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
84
client/uiwails/grpc.go
Normal file
@@ -0,0 +1,84 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultFailTimeout = 3 * time.Second
|
||||
failFastTimeout = time.Second
|
||||
)
|
||||
|
||||
// GRPCClient manages a single persistent gRPC connection to the NetBird daemon.
|
||||
type GRPCClient struct {
|
||||
addr string
|
||||
mu sync.Mutex
|
||||
conn *grpc.ClientConn
|
||||
client proto.DaemonServiceClient
|
||||
}
|
||||
|
||||
// NewGRPCClient creates a new GRPCClient for the given daemon address.
|
||||
func NewGRPCClient(addr string) *GRPCClient {
|
||||
return &GRPCClient{addr: addr}
|
||||
}
|
||||
|
||||
// GetClient returns a cached DaemonServiceClient, creating the connection on first use.
|
||||
func (g *GRPCClient) GetClient(timeout time.Duration) (proto.DaemonServiceClient, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.client != nil {
|
||||
return g.client, nil
|
||||
}
|
||||
|
||||
target := g.addr
|
||||
if strings.HasPrefix(target, "tcp://") {
|
||||
target = strings.TrimPrefix(target, "tcp://")
|
||||
} else if strings.HasPrefix(target, "unix://") {
|
||||
target = "unix:" + strings.TrimPrefix(target, "unix://")
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
target,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithUserAgent(getUIUserAgent()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g.conn = conn
|
||||
g.client = proto.NewDaemonServiceClient(conn)
|
||||
log.Debugf("gRPC connection established to %s", g.addr)
|
||||
|
||||
return g.client, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying gRPC connection.
|
||||
func (g *GRPCClient) Close() error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.conn != nil {
|
||||
err := g.conn.Close()
|
||||
g.conn = nil
|
||||
g.client = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getUIUserAgent() string {
|
||||
return "netbird-fancyui/" + version.NetbirdVersion()
|
||||
}
|
||||
31
client/uiwails/icons.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed assets/netbird-systemtray-disconnected.png
|
||||
var iconDisconnected []byte
|
||||
|
||||
//go:embed assets/netbird-systemtray-connected.png
|
||||
var iconConnected []byte
|
||||
|
||||
//go:embed assets/netbird-systemtray-connecting.png
|
||||
var iconConnecting []byte
|
||||
|
||||
//go:embed assets/netbird-systemtray-error.png
|
||||
var iconError []byte
|
||||
|
||||
// iconForStatus returns the appropriate tray icon bytes for the given status string.
|
||||
func iconForStatus(status string) []byte {
|
||||
switch status {
|
||||
case "Connected":
|
||||
return iconConnected
|
||||
case "Connecting":
|
||||
return iconConnecting
|
||||
case "Disconnected", "":
|
||||
return iconDisconnected
|
||||
default:
|
||||
return iconError
|
||||
}
|
||||
}
|
||||
129
client/uiwails/main.go
Normal file
@@ -0,0 +1,129 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"flag"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
|
||||
"github.com/netbirdio/netbird/client/uiwails/event"
|
||||
"github.com/netbirdio/netbird/client/uiwails/process"
|
||||
"github.com/netbirdio/netbird/client/uiwails/services"
|
||||
)
|
||||
|
||||
//go:embed frontend/dist
|
||||
var frontendFS embed.FS
|
||||
|
||||
var (
|
||||
daemonAddr = flag.String("daemon-addr", defaultDaemonAddr(), "NetBird daemon gRPC address")
|
||||
)
|
||||
|
||||
func defaultDaemonAddr() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "tcp://127.0.0.1:41731"
|
||||
}
|
||||
return "unix:///var/run/netbird.sock"
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Single-instance guard — if another instance is running, show its window and exit.
|
||||
if pid, running, err := process.IsAnotherProcessRunning(); err == nil && running {
|
||||
log.Infof("another instance is running (pid %d), signalling it to show window", pid)
|
||||
if err := sendShowWindowSignal(pid); err != nil {
|
||||
log.Warnf("send show window signal: %v", err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
grpcClient := NewGRPCClient(*daemonAddr)
|
||||
|
||||
connSvc := services.NewConnectionService(grpcClient)
|
||||
settingsSvc := services.NewSettingsService(grpcClient)
|
||||
networkSvc := services.NewNetworkService(grpcClient)
|
||||
profileSvc := services.NewProfileService(grpcClient)
|
||||
peersSvc := services.NewPeersService(grpcClient)
|
||||
debugSvc := services.NewDebugService(grpcClient)
|
||||
updateSvc := services.NewUpdateService(grpcClient)
|
||||
notifSvc := notifications.New()
|
||||
|
||||
app := application.New(application.Options{
|
||||
Name: "NetBird",
|
||||
Description: "NetBird VPN client",
|
||||
Services: []application.Service{
|
||||
application.NewService(connSvc),
|
||||
application.NewService(settingsSvc),
|
||||
application.NewService(networkSvc),
|
||||
application.NewService(profileSvc),
|
||||
application.NewService(peersSvc),
|
||||
application.NewService(debugSvc),
|
||||
application.NewService(updateSvc),
|
||||
application.NewService(notifSvc),
|
||||
},
|
||||
Assets: application.AssetOptions{
|
||||
Handler: application.BundledAssetFileServer(frontendFS),
|
||||
},
|
||||
Mac: application.MacOptions{
|
||||
ActivationPolicy: application.ActivationPolicyAccessory,
|
||||
},
|
||||
})
|
||||
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "NetBird",
|
||||
Width: 900,
|
||||
Height: 650,
|
||||
Hidden: true, // start hidden — tray is the primary interface
|
||||
URL: "/",
|
||||
AlwaysOnTop: false,
|
||||
DisableResize: false,
|
||||
Windows: application.WindowsWindow{
|
||||
HiddenOnTaskbar: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Hide instead of quit when user closes the window.
|
||||
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
e.Cancel()
|
||||
window.Hide()
|
||||
})
|
||||
|
||||
// Register an in-process StatusNotifierWatcher so the tray works on
|
||||
// minimal WMs (Fluxbox, OpenBox, i3…) that don't ship one themselves.
|
||||
startStatusNotifierWatcher()
|
||||
|
||||
tray := newTrayManager(app, window, connSvc, settingsSvc, networkSvc, profileSvc)
|
||||
tray.Setup(iconDisconnected)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Signal handler: SIGUSR1 on Unix, Windows Event on Windows.
|
||||
setupSignalHandler(ctx, window)
|
||||
|
||||
// Daemon event stream → desktop notifications.
|
||||
notify := func(title, body string) {
|
||||
if err := notifSvc.SendNotification(notifications.NotificationOptions{
|
||||
ID: "netbird-event",
|
||||
Title: title,
|
||||
Body: body,
|
||||
}); err != nil {
|
||||
log.Warnf("send notification: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
evtManager := event.NewManager(*daemonAddr, notify)
|
||||
go evtManager.Start(ctx)
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("app run: %v", err)
|
||||
}
|
||||
}
|
||||
39
client/uiwails/process/process.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
)
|
||||
|
||||
// IsAnotherProcessRunning returns the PID and true if another instance of the
|
||||
// same binary is already running for the current OS user.
|
||||
func IsAnotherProcessRunning() (int32, bool, error) {
|
||||
processes, err := process.Processes()
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
pid := os.Getpid()
|
||||
processName := strings.ToLower(filepath.Base(os.Args[0]))
|
||||
|
||||
for _, p := range processes {
|
||||
if int(p.Pid) == pid {
|
||||
continue
|
||||
}
|
||||
|
||||
runningProcessPath, err := p.Exe()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
runningProcessName := strings.ToLower(filepath.Base(runningProcessPath))
|
||||
if runningProcessName == processName && isProcessOwnedByCurrentUser(p) {
|
||||
return p.Pid, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false, nil
|
||||
}
|
||||
25
client/uiwails/process/process_nonwindows.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build !windows
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func isProcessOwnedByCurrentUser(p *process.Process) bool {
|
||||
currentUserID := os.Getuid()
|
||||
uids, err := p.Uids()
|
||||
if err != nil {
|
||||
log.Errorf("get process uids: %v", err)
|
||||
return false
|
||||
}
|
||||
for _, id := range uids {
|
||||
if int(id) == currentUserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
24
client/uiwails/process/process_windows.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func isProcessOwnedByCurrentUser(p *process.Process) bool {
|
||||
processUsername, err := p.Username()
|
||||
if err != nil {
|
||||
log.Errorf("get process username error: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
log.Errorf("get current user error: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return processUsername == currUser.Username
|
||||
}
|
||||
112
client/uiwails/services/connection.go
Normal file
@@ -0,0 +1,112 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// ConnectionService exposes connect/disconnect/status operations to the Wails frontend.
|
||||
type ConnectionService struct {
|
||||
grpcClient GRPCClientIface
|
||||
}
|
||||
|
||||
// NewConnectionService creates a new ConnectionService.
|
||||
func NewConnectionService(g GRPCClientIface) *ConnectionService {
|
||||
return &ConnectionService{grpcClient: g}
|
||||
}
|
||||
|
||||
// GetStatus returns the current daemon status.
|
||||
func (s *ConnectionService) GetStatus() (*StatusInfo, error) {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
log.Debugf("GetStatus: failed to get gRPC client: %v", err)
|
||||
return nil, fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := conn.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
|
||||
if err != nil {
|
||||
log.Warnf("GetStatus: status RPC failed: %v", err)
|
||||
return nil, fmt.Errorf("status rpc: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("GetStatus: daemon responded status=%q daemonVersion=%q fullStatus=%v",
|
||||
resp.Status, resp.DaemonVersion, resp.FullStatus != nil)
|
||||
|
||||
info := &StatusInfo{
|
||||
Status: resp.Status,
|
||||
}
|
||||
|
||||
if resp.FullStatus != nil && resp.FullStatus.LocalPeerState != nil {
|
||||
lp := resp.FullStatus.LocalPeerState
|
||||
info.IP = lp.GetIP()
|
||||
info.PublicKey = lp.GetPubKey()
|
||||
info.Fqdn = lp.GetFqdn()
|
||||
log.Debugf("GetStatus: localPeer ip=%q fqdn=%q pubKey=%q", info.IP, info.Fqdn, info.PublicKey)
|
||||
} else if resp.FullStatus == nil {
|
||||
log.Warnf("GetStatus: fullStatus is nil — daemon may not support full status or request flag was not set")
|
||||
} else {
|
||||
log.Debugf("GetStatus: fullStatus present but LocalPeerState is nil")
|
||||
}
|
||||
|
||||
if resp.FullStatus != nil {
|
||||
info.ConnectedPeers = len(resp.FullStatus.GetPeers())
|
||||
log.Debugf("GetStatus: connectedPeers=%d", info.ConnectedPeers)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Connect sends an Up request to the daemon.
|
||||
func (s *ConnectionService) Connect() error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := conn.Up(ctx, &proto.UpRequest{}); err != nil {
|
||||
log.Errorf("Up rpc failed: %v", err)
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect sends a Down request to the daemon.
|
||||
func (s *ConnectionService) Disconnect() error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := conn.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||
log.Errorf("Down rpc failed: %v", err)
|
||||
return fmt.Errorf("disconnect: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StatusInfo holds simplified status information for the frontend.
|
||||
type StatusInfo struct {
|
||||
Status string `json:"status"`
|
||||
IP string `json:"ip"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
Fqdn string `json:"fqdn"`
|
||||
ConnectedPeers int `json:"connectedPeers"`
|
||||
}
|
||||
196
client/uiwails/services/debug.go
Normal file
@@ -0,0 +1,196 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// DebugService exposes debug bundle creation and log-level control to the Wails frontend.
|
||||
type DebugService struct {
|
||||
grpcClient GRPCClientIface
|
||||
}
|
||||
|
||||
// NewDebugService creates a new DebugService.
|
||||
func NewDebugService(g GRPCClientIface) *DebugService {
|
||||
return &DebugService{grpcClient: g}
|
||||
}
|
||||
|
||||
// DebugBundleParams holds the parameters for creating a debug bundle.
|
||||
type DebugBundleParams struct {
|
||||
Anonymize bool `json:"anonymize"`
|
||||
SystemInfo bool `json:"systemInfo"`
|
||||
Upload bool `json:"upload"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
RunDurationMins int `json:"runDurationMins"`
|
||||
EnablePersistence bool `json:"enablePersistence"`
|
||||
}
|
||||
|
||||
// DebugBundleResult holds the result of creating a debug bundle.
|
||||
type DebugBundleResult struct {
|
||||
LocalPath string `json:"localPath"`
|
||||
UploadedKey string `json:"uploadedKey"`
|
||||
UploadFailureReason string `json:"uploadFailureReason"`
|
||||
}
|
||||
|
||||
// CreateDebugBundle creates a debug bundle via the daemon.
|
||||
func (s *DebugService) CreateDebugBundle(params DebugBundleParams) (*DebugBundleResult, error) {
|
||||
conn, err := s.grpcClient.GetClient(time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if params.RunDurationMins > 0 {
|
||||
if err := s.configureForDebug(ctx, conn, params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
req := &proto.DebugBundleRequest{
|
||||
Anonymize: params.Anonymize,
|
||||
SystemInfo: params.SystemInfo,
|
||||
}
|
||||
if params.Upload && params.UploadURL != "" {
|
||||
req.UploadURL = params.UploadURL
|
||||
}
|
||||
|
||||
resp, err := conn.DebugBundle(ctx, req)
|
||||
if err != nil {
|
||||
log.Errorf("DebugBundle rpc failed: %v", err)
|
||||
return nil, fmt.Errorf("create debug bundle: %w", err)
|
||||
}
|
||||
|
||||
return &DebugBundleResult{
|
||||
LocalPath: resp.GetPath(),
|
||||
UploadedKey: resp.GetUploadedKey(),
|
||||
UploadFailureReason: resp.GetUploadFailureReason(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DebugService) configureForDebug(ctx context.Context, conn proto.DaemonServiceClient, params DebugBundleParams) error {
|
||||
statusResp, err := conn.Status(ctx, &proto.StatusRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get status: %w", err)
|
||||
}
|
||||
|
||||
wasConnected := statusResp.Status == "Connected" || statusResp.Status == "Connecting"
|
||||
|
||||
logLevelResp, err := conn.GetLogLevel(ctx, &proto.GetLogLevelRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get log level: %w", err)
|
||||
}
|
||||
originalLogLevel := logLevelResp.GetLevel()
|
||||
|
||||
// Set trace log level
|
||||
if _, err := conn.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel_TRACE}); err != nil {
|
||||
return fmt.Errorf("set log level: %w", err)
|
||||
}
|
||||
|
||||
// Bring service down then up to capture full connection logs
|
||||
if _, err := conn.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||
log.Warnf("bring down for debug: %v", err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
|
||||
if params.EnablePersistence {
|
||||
if _, err := conn.SetSyncResponsePersistence(ctx, &proto.SetSyncResponsePersistenceRequest{Enabled: true}); err != nil {
|
||||
log.Warnf("enable sync persistence: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := conn.Up(ctx, &proto.UpRequest{}); err != nil {
|
||||
return fmt.Errorf("bring service up: %w", err)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
if _, err := conn.StartCPUProfile(ctx, &proto.StartCPUProfileRequest{}); err != nil {
|
||||
log.Warnf("start CPU profiling: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the collection duration
|
||||
collectionDur := time.Duration(params.RunDurationMins) * time.Minute
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(collectionDur):
|
||||
}
|
||||
|
||||
if _, err := conn.StopCPUProfile(ctx, &proto.StopCPUProfileRequest{}); err != nil {
|
||||
log.Warnf("stop CPU profiling: %v", err)
|
||||
}
|
||||
|
||||
// Restore original state
|
||||
if !wasConnected {
|
||||
if _, err := conn.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||
log.Warnf("restore down state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if originalLogLevel < proto.LogLevel_TRACE {
|
||||
if _, err := conn.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: originalLogLevel}); err != nil {
|
||||
log.Warnf("restore log level: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLogLevel returns the current daemon log level.
|
||||
func (s *DebugService) GetLogLevel() (string, error) {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := conn.GetLogLevel(ctx, &proto.GetLogLevelRequest{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get log level rpc: %w", err)
|
||||
}
|
||||
|
||||
return resp.GetLevel().String(), nil
|
||||
}
|
||||
|
||||
// SetLogLevel sets the daemon log level.
|
||||
func (s *DebugService) SetLogLevel(level string) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var protoLevel proto.LogLevel
|
||||
switch level {
|
||||
case "TRACE":
|
||||
protoLevel = proto.LogLevel_TRACE
|
||||
case "DEBUG":
|
||||
protoLevel = proto.LogLevel_DEBUG
|
||||
case "INFO":
|
||||
protoLevel = proto.LogLevel_INFO
|
||||
case "WARN", "WARNING":
|
||||
protoLevel = proto.LogLevel_WARN
|
||||
case "ERROR":
|
||||
protoLevel = proto.LogLevel_ERROR
|
||||
default:
|
||||
protoLevel = proto.LogLevel_INFO
|
||||
}
|
||||
|
||||
if _, err := conn.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: protoLevel}); err != nil {
|
||||
return fmt.Errorf("set log level rpc: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
14
client/uiwails/services/grpc_iface.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// GRPCClientIface is the interface services use to obtain a daemon client.
|
||||
type GRPCClientIface interface {
|
||||
GetClient(timeout time.Duration) (proto.DaemonServiceClient, error)
|
||||
}
|
||||
222
client/uiwails/services/network.go
Normal file
@@ -0,0 +1,222 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// NetworkService exposes network/route management to the Wails frontend.
|
||||
type NetworkService struct {
|
||||
grpcClient GRPCClientIface
|
||||
}
|
||||
|
||||
// NewNetworkService creates a new NetworkService.
|
||||
func NewNetworkService(g GRPCClientIface) *NetworkService {
|
||||
return &NetworkService{grpcClient: g}
|
||||
}
|
||||
|
||||
// NetworkInfo is a serializable view of a single network/route.
|
||||
type NetworkInfo struct {
|
||||
ID string `json:"id"`
|
||||
Range string `json:"range"`
|
||||
Domains []string `json:"domains"`
|
||||
Selected bool `json:"selected"`
|
||||
ResolvedIPs map[string][]string `json:"resolvedIPs"`
|
||||
}
|
||||
|
||||
// ListNetworks returns all networks from the daemon.
|
||||
func (s *NetworkService) ListNetworks() ([]NetworkInfo, error) {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := conn.ListNetworks(ctx, &proto.ListNetworksRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list networks rpc: %w", err)
|
||||
}
|
||||
|
||||
routes := make([]NetworkInfo, 0, len(resp.Routes))
|
||||
for _, r := range resp.Routes {
|
||||
info := NetworkInfo{
|
||||
ID: r.GetID(),
|
||||
Range: r.GetRange(),
|
||||
Domains: r.GetDomains(),
|
||||
Selected: r.GetSelected(),
|
||||
}
|
||||
if resolvedMap := r.GetResolvedIPs(); resolvedMap != nil {
|
||||
info.ResolvedIPs = make(map[string][]string)
|
||||
for domain, ipList := range resolvedMap {
|
||||
info.ResolvedIPs[domain] = ipList.GetIps()
|
||||
}
|
||||
}
|
||||
routes = append(routes, info)
|
||||
}
|
||||
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
return strings.ToLower(routes[i].ID) < strings.ToLower(routes[j].ID)
|
||||
})
|
||||
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
// ListOverlappingNetworks returns only networks with overlapping ranges.
|
||||
func (s *NetworkService) ListOverlappingNetworks() ([]NetworkInfo, error) {
|
||||
all, err := s.ListNetworks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingRange := make(map[string][]NetworkInfo)
|
||||
for _, r := range all {
|
||||
if len(r.Domains) > 0 {
|
||||
continue
|
||||
}
|
||||
existingRange[r.Range] = append(existingRange[r.Range], r)
|
||||
}
|
||||
|
||||
var result []NetworkInfo
|
||||
for _, group := range existingRange {
|
||||
if len(group) > 1 {
|
||||
result = append(result, group...)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListExitNodes returns networks with range 0.0.0.0/0 (exit nodes).
|
||||
func (s *NetworkService) ListExitNodes() ([]NetworkInfo, error) {
|
||||
all, err := s.ListNetworks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []NetworkInfo
|
||||
for _, r := range all {
|
||||
if r.Range == "0.0.0.0/0" {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SelectNetwork selects a single network by ID.
|
||||
func (s *NetworkService) SelectNetwork(id string) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := &proto.SelectNetworksRequest{
|
||||
NetworkIDs: []string{id},
|
||||
Append: true,
|
||||
}
|
||||
if _, err := conn.SelectNetworks(ctx, req); err != nil {
|
||||
log.Errorf("SelectNetworks rpc failed: %v", err)
|
||||
return fmt.Errorf("select network: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeselectNetwork deselects a single network by ID.
|
||||
func (s *NetworkService) DeselectNetwork(id string) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := &proto.SelectNetworksRequest{
|
||||
NetworkIDs: []string{id},
|
||||
}
|
||||
if _, err := conn.DeselectNetworks(ctx, req); err != nil {
|
||||
log.Errorf("DeselectNetworks rpc failed: %v", err)
|
||||
return fmt.Errorf("deselect network: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectAllNetworks selects all networks.
|
||||
func (s *NetworkService) SelectAllNetworks() error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := &proto.SelectNetworksRequest{All: true}
|
||||
if _, err := conn.SelectNetworks(ctx, req); err != nil {
|
||||
return fmt.Errorf("select all networks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeselectAllNetworks deselects all networks.
|
||||
func (s *NetworkService) DeselectAllNetworks() error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := &proto.SelectNetworksRequest{All: true}
|
||||
if _, err := conn.DeselectNetworks(ctx, req); err != nil {
|
||||
return fmt.Errorf("deselect all networks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectNetworks selects a list of networks by ID.
|
||||
func (s *NetworkService) SelectNetworks(ids []string) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := &proto.SelectNetworksRequest{NetworkIDs: ids, Append: true}
|
||||
if _, err := conn.SelectNetworks(ctx, req); err != nil {
|
||||
return fmt.Errorf("select networks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeselectNetworks deselects a list of networks by ID.
|
||||
func (s *NetworkService) DeselectNetworks(ids []string) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := &proto.SelectNetworksRequest{NetworkIDs: ids}
|
||||
if _, err := conn.DeselectNetworks(ctx, req); err != nil {
|
||||
return fmt.Errorf("deselect networks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
106
client/uiwails/services/peers.go
Normal file
@@ -0,0 +1,106 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// PeersService exposes peer listing operations to the Wails frontend.
|
||||
type PeersService struct {
|
||||
grpcClient GRPCClientIface
|
||||
}
|
||||
|
||||
// NewPeersService creates a new PeersService.
|
||||
func NewPeersService(g GRPCClientIface) *PeersService {
|
||||
return &PeersService{grpcClient: g}
|
||||
}
|
||||
|
||||
// GetPeers returns the list of all peers with their status information.
|
||||
func (s *PeersService) GetPeers() ([]PeerInfo, error) {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
log.Debugf("GetPeers: failed to get gRPC client: %v", err)
|
||||
return nil, fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := conn.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
|
||||
if err != nil {
|
||||
log.Warnf("GetPeers: status RPC failed: %v", err)
|
||||
return nil, fmt.Errorf("status rpc: %w", err)
|
||||
}
|
||||
|
||||
if resp.FullStatus == nil {
|
||||
log.Debugf("GetPeers: fullStatus is nil")
|
||||
return []PeerInfo{}, nil
|
||||
}
|
||||
|
||||
peers := resp.FullStatus.GetPeers()
|
||||
log.Debugf("GetPeers: got %d peers from daemon", len(peers))
|
||||
|
||||
result := make([]PeerInfo, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
info := PeerInfo{
|
||||
IP: p.GetIP(),
|
||||
PubKey: p.GetPubKey(),
|
||||
Fqdn: p.GetFqdn(),
|
||||
ConnStatus: p.GetConnStatus(),
|
||||
Relayed: p.GetRelayed(),
|
||||
RelayAddress: p.GetRelayAddress(),
|
||||
BytesRx: p.GetBytesRx(),
|
||||
BytesTx: p.GetBytesTx(),
|
||||
RosenpassEnabled: p.GetRosenpassEnabled(),
|
||||
Networks: p.GetNetworks(),
|
||||
LocalIceType: p.GetLocalIceCandidateType(),
|
||||
RemoteIceType: p.GetRemoteIceCandidateType(),
|
||||
LocalEndpoint: p.GetLocalIceCandidateEndpoint(),
|
||||
RemoteEndpoint: p.GetRemoteIceCandidateEndpoint(),
|
||||
}
|
||||
|
||||
if lat := p.GetLatency(); lat != nil {
|
||||
info.LatencyMs = float64(lat.Seconds)*1000 + float64(lat.Nanos)/1e6
|
||||
}
|
||||
|
||||
if ts := p.GetLastWireguardHandshake(); ts != nil {
|
||||
info.LastHandshake = ts.AsTime().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
if ts := p.GetConnStatusUpdate(); ts != nil {
|
||||
info.ConnStatusUpdate = ts.AsTime().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
result = append(result, info)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PeerInfo holds simplified peer information for the frontend.
|
||||
type PeerInfo struct {
|
||||
IP string `json:"ip"`
|
||||
PubKey string `json:"pubKey"`
|
||||
Fqdn string `json:"fqdn"`
|
||||
ConnStatus string `json:"connStatus"`
|
||||
ConnStatusUpdate string `json:"connStatusUpdate"`
|
||||
Relayed bool `json:"relayed"`
|
||||
RelayAddress string `json:"relayAddress"`
|
||||
LatencyMs float64 `json:"latencyMs"`
|
||||
BytesRx int64 `json:"bytesRx"`
|
||||
BytesTx int64 `json:"bytesTx"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
Networks []string `json:"networks"`
|
||||
LastHandshake string `json:"lastHandshake"`
|
||||
LocalIceType string `json:"localIceType"`
|
||||
RemoteIceType string `json:"remoteIceType"`
|
||||
LocalEndpoint string `json:"localEndpoint"`
|
||||
RemoteEndpoint string `json:"remoteEndpoint"`
|
||||
}
|
||||
195
client/uiwails/services/profile.go
Normal file
@@ -0,0 +1,195 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/user"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// ProfileService exposes profile management to the Wails frontend.
|
||||
type ProfileService struct {
|
||||
grpcClient GRPCClientIface
|
||||
}
|
||||
|
||||
// NewProfileService creates a new ProfileService.
|
||||
func NewProfileService(g GRPCClientIface) *ProfileService {
|
||||
return &ProfileService{grpcClient: g}
|
||||
}
|
||||
|
||||
// ProfileInfo is a serializable view of a profile.
|
||||
type ProfileInfo struct {
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"isActive"`
|
||||
}
|
||||
|
||||
// ActiveProfileInfo holds information about the currently active profile.
|
||||
type ActiveProfileInfo struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// ListProfiles returns all profiles for the current OS user.
|
||||
func (s *ProfileService) ListProfiles() ([]ProfileInfo, error) {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := conn.ListProfiles(ctx, &proto.ListProfilesRequest{
|
||||
Username: currUser.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list profiles rpc: %w", err)
|
||||
}
|
||||
|
||||
profiles := make([]ProfileInfo, 0, len(resp.Profiles))
|
||||
for _, p := range resp.Profiles {
|
||||
profiles = append(profiles, ProfileInfo{
|
||||
Name: p.Name,
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the currently active profile.
|
||||
func (s *ProfileService) GetActiveProfile() (*ActiveProfileInfo, error) {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := conn.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active profile rpc: %w", err)
|
||||
}
|
||||
|
||||
return &ActiveProfileInfo{
|
||||
ProfileName: resp.ProfileName,
|
||||
Username: resp.Username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SwitchProfile switches to the named profile.
|
||||
func (s *ProfileService) SwitchProfile(profileName string) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{
|
||||
ProfileName: &profileName,
|
||||
Username: &currUser.Username,
|
||||
}); err != nil {
|
||||
log.Errorf("SwitchProfile rpc failed: %v", err)
|
||||
return fmt.Errorf("switch profile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddProfile creates a new profile with the given name.
|
||||
func (s *ProfileService) AddProfile(profileName string) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := conn.AddProfile(ctx, &proto.AddProfileRequest{
|
||||
ProfileName: profileName,
|
||||
Username: currUser.Username,
|
||||
}); err != nil {
|
||||
log.Errorf("AddProfile rpc failed: %v", err)
|
||||
return fmt.Errorf("add profile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProfile removes the named profile.
|
||||
func (s *ProfileService) RemoveProfile(profileName string) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := conn.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
ProfileName: profileName,
|
||||
Username: currUser.Username,
|
||||
}); err != nil {
|
||||
log.Errorf("RemoveProfile rpc failed: %v", err)
|
||||
return fmt.Errorf("remove profile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout deregisters the named profile.
|
||||
func (s *ProfileService) Logout(profileName string) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
username := currUser.Username
|
||||
if _, err := conn.Logout(ctx, &proto.LogoutRequest{
|
||||
ProfileName: &profileName,
|
||||
Username: &username,
|
||||
}); err != nil {
|
||||
log.Errorf("Logout rpc failed: %v", err)
|
||||
return fmt.Errorf("logout: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
165
client/uiwails/services/settings.go
Normal file
@@ -0,0 +1,165 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// SettingsService exposes config get/set operations to the Wails frontend.
|
||||
type SettingsService struct {
|
||||
grpcClient GRPCClientIface
|
||||
}
|
||||
|
||||
// NewSettingsService creates a new SettingsService.
|
||||
func NewSettingsService(g GRPCClientIface) *SettingsService {
|
||||
return &SettingsService{grpcClient: g}
|
||||
}
|
||||
|
||||
// ConfigInfo is a serializable view of the daemon configuration.
|
||||
type ConfigInfo struct {
|
||||
ManagementURL string `json:"managementUrl"`
|
||||
AdminURL string `json:"adminUrl"`
|
||||
PreSharedKey string `json:"preSharedKey"`
|
||||
InterfaceName string `json:"interfaceName"`
|
||||
WireguardPort int64 `json:"wireguardPort"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
ServerSSHAllowed bool `json:"serverSshAllowed"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled"`
|
||||
BlockInbound bool `json:"blockInbound"`
|
||||
DisableNotifications bool `json:"disableNotifications"`
|
||||
}
|
||||
|
||||
// GetConfig retrieves the daemon configuration.
|
||||
func (s *SettingsService) GetConfig() (*ConfigInfo, error) {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := conn.GetConfig(ctx, &proto.GetConfigRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config rpc: %w", err)
|
||||
}
|
||||
|
||||
cfg := &ConfigInfo{
|
||||
ManagementURL: resp.ManagementUrl,
|
||||
AdminURL: resp.AdminURL,
|
||||
PreSharedKey: resp.PreSharedKey,
|
||||
InterfaceName: resp.InterfaceName,
|
||||
WireguardPort: resp.WireguardPort,
|
||||
DisableAutoConnect: resp.DisableAutoConnect,
|
||||
ServerSSHAllowed: resp.ServerSSHAllowed,
|
||||
RosenpassEnabled: resp.RosenpassEnabled,
|
||||
LazyConnectionEnabled: resp.LazyConnectionEnabled,
|
||||
BlockInbound: resp.BlockInbound,
|
||||
DisableNotifications: resp.DisableNotifications,
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetConfig pushes configuration changes to the daemon.
|
||||
func (s *SettingsService) SetConfig(cfg ConfigInfo) error {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// The SetConfigRequest uses optional pointer fields for most settings.
|
||||
req := &proto.SetConfigRequest{
|
||||
ManagementUrl: cfg.ManagementURL,
|
||||
AdminURL: cfg.AdminURL,
|
||||
RosenpassEnabled: &cfg.RosenpassEnabled,
|
||||
InterfaceName: &cfg.InterfaceName,
|
||||
WireguardPort: &cfg.WireguardPort,
|
||||
OptionalPreSharedKey: &cfg.PreSharedKey,
|
||||
DisableAutoConnect: &cfg.DisableAutoConnect,
|
||||
ServerSSHAllowed: &cfg.ServerSSHAllowed,
|
||||
RosenpassPermissive: &cfg.RosenpassPermissive,
|
||||
DisableNotifications: &cfg.DisableNotifications,
|
||||
LazyConnectionEnabled: &cfg.LazyConnectionEnabled,
|
||||
BlockInbound: &cfg.BlockInbound,
|
||||
}
|
||||
|
||||
if _, err := conn.SetConfig(ctx, req); err != nil {
|
||||
log.Errorf("SetConfig rpc failed: %v", err)
|
||||
return fmt.Errorf("set config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToggleSSH toggles the SSH server allowed setting.
|
||||
func (s *SettingsService) ToggleSSH(enabled bool) error {
|
||||
cfg, err := s.GetConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.ServerSSHAllowed = enabled
|
||||
return s.SetConfig(*cfg)
|
||||
}
|
||||
|
||||
// ToggleAutoConnect toggles the auto-connect setting.
|
||||
func (s *SettingsService) ToggleAutoConnect(enabled bool) error {
|
||||
cfg, err := s.GetConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.DisableAutoConnect = !enabled
|
||||
return s.SetConfig(*cfg)
|
||||
}
|
||||
|
||||
// ToggleRosenpass toggles the Rosenpass quantum resistance setting.
|
||||
func (s *SettingsService) ToggleRosenpass(enabled bool) error {
|
||||
cfg, err := s.GetConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.RosenpassEnabled = enabled
|
||||
return s.SetConfig(*cfg)
|
||||
}
|
||||
|
||||
// ToggleLazyConn toggles the lazy connections setting.
|
||||
func (s *SettingsService) ToggleLazyConn(enabled bool) error {
|
||||
cfg, err := s.GetConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LazyConnectionEnabled = enabled
|
||||
return s.SetConfig(*cfg)
|
||||
}
|
||||
|
||||
// ToggleBlockInbound toggles the block inbound setting.
|
||||
func (s *SettingsService) ToggleBlockInbound(enabled bool) error {
|
||||
cfg, err := s.GetConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.BlockInbound = enabled
|
||||
return s.SetConfig(*cfg)
|
||||
}
|
||||
|
||||
// ToggleNotifications toggles the notifications setting.
|
||||
func (s *SettingsService) ToggleNotifications(enabled bool) error {
|
||||
cfg, err := s.GetConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.DisableNotifications = !enabled
|
||||
return s.SetConfig(*cfg)
|
||||
}
|
||||
56
client/uiwails/services/update.go
Normal file
@@ -0,0 +1,56 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// UpdateService exposes update triggering and result polling to the Wails frontend.
|
||||
type UpdateService struct {
|
||||
grpcClient GRPCClientIface
|
||||
}
|
||||
|
||||
// NewUpdateService creates a new UpdateService.
|
||||
func NewUpdateService(g GRPCClientIface) *UpdateService {
|
||||
return &UpdateService{grpcClient: g}
|
||||
}
|
||||
|
||||
// InstallerResult holds the result of an installer run.
|
||||
type InstallerResult struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
|
||||
// TriggerUpdate requests the daemon to perform an auto-update.
|
||||
func (s *UpdateService) TriggerUpdate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstallerResult polls for the installer result (blocking until complete or timeout).
|
||||
func (s *UpdateService) GetInstallerResult() (*InstallerResult, error) {
|
||||
conn, err := s.grpcClient.GetClient(3 * time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
resp, err := conn.GetInstallerResult(ctx, &proto.InstallerResultRequest{})
|
||||
if err != nil {
|
||||
log.Infof("GetInstallerResult ended (daemon may have restarted): %v", err)
|
||||
return &InstallerResult{Success: true}, nil
|
||||
}
|
||||
|
||||
return &InstallerResult{
|
||||
Success: resp.Success,
|
||||
ErrorMsg: resp.ErrorMsg,
|
||||
}, nil
|
||||
}
|
||||
40
client/uiwails/signal_unix.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build !windows && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// setupSignalHandler listens for SIGUSR1 and shows the main window when received.
|
||||
func setupSignalHandler(ctx context.Context, window *application.WebviewWindow) {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGUSR1)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-sigChan:
|
||||
log.Info("received SIGUSR1 signal, showing window")
|
||||
window.Show()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// sendShowWindowSignal sends SIGUSR1 to an already-running instance to trigger window show.
|
||||
func sendShowWindowSignal(pid int32) error {
|
||||
proc, err := os.FindProcess(int(pid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return proc.Signal(syscall.SIGUSR1)
|
||||
}
|
||||
84
client/uiwails/signal_windows.go
Normal file
@@ -0,0 +1,84 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
fancyUITriggerEventName = `Global\NetBirdFancyUITriggerEvent`
|
||||
waitTimeout = 5 * time.Second
|
||||
desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE
|
||||
)
|
||||
|
||||
// setupSignalHandler sets up a Windows Event-based signal handler.
|
||||
// When triggered, it shows the main window.
|
||||
func setupSignalHandler(ctx context.Context, window *application.WebviewWindow) {
|
||||
eventNamePtr, err := windows.UTF16PtrFromString(fancyUITriggerEventName)
|
||||
if err != nil {
|
||||
log.Errorf("convert event name to UTF16: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
eventHandle, err := windows.CreateEvent(nil, 1, 0, eventNamePtr)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
|
||||
eventHandle, err = windows.OpenEvent(desiredAccesses, false, eventNamePtr)
|
||||
if err != nil {
|
||||
log.Errorf("open existing trigger event: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.Errorf("create trigger event: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if eventHandle == windows.InvalidHandle {
|
||||
log.Errorf("invalid handle for trigger event")
|
||||
return
|
||||
}
|
||||
|
||||
go waitForWindowsEvent(ctx, eventHandle, window)
|
||||
}
|
||||
|
||||
func waitForWindowsEvent(ctx context.Context, eventHandle windows.Handle, window *application.WebviewWindow) {
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(eventHandle); err != nil {
|
||||
log.Errorf("close event handle: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := windows.WaitForSingleObject(eventHandle, uint32(waitTimeout.Milliseconds()))
|
||||
|
||||
switch status {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
log.Info("received trigger event signal, showing window")
|
||||
if err := windows.ResetEvent(eventHandle); err != nil {
|
||||
log.Errorf("reset event: %v", err)
|
||||
}
|
||||
window.Show()
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
// Timeout is expected — loop and poll again.
|
||||
default:
|
||||
log.Errorf("unexpected WaitForSingleObject status %d: %v", status, err)
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
429
client/uiwails/tray.go
Normal file
@@ -0,0 +1,429 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"github.com/netbirdio/netbird/client/uiwails/services"
|
||||
)
|
||||
|
||||
const statusPollInterval = 5 * time.Second
|
||||
|
||||
// trayManager manages the system tray state and menu.
|
||||
type trayManager struct {
|
||||
app *application.App
|
||||
window *application.WebviewWindow
|
||||
tray *application.SystemTray
|
||||
menu *application.Menu
|
||||
|
||||
connSvc *services.ConnectionService
|
||||
settingsSvc *services.SettingsService
|
||||
networkSvc *services.NetworkService
|
||||
profileSvc *services.ProfileService
|
||||
|
||||
mu sync.Mutex
|
||||
statusItem *application.MenuItem
|
||||
exitNodeMenu *application.Menu
|
||||
|
||||
// toggle items tracked for updating checked state
|
||||
sshItem *application.MenuItem
|
||||
autoConnectItem *application.MenuItem
|
||||
rosenpassItem *application.MenuItem
|
||||
lazyConnItem *application.MenuItem
|
||||
blockInboundItem *application.MenuItem
|
||||
notificationsItem *application.MenuItem
|
||||
|
||||
exitNodeItems []*application.MenuItem
|
||||
exitNodeStates []exitNodeState
|
||||
}
|
||||
|
||||
type exitNodeState struct {
|
||||
id string
|
||||
selected bool
|
||||
}
|
||||
|
||||
func newTrayManager(
|
||||
app *application.App,
|
||||
window *application.WebviewWindow,
|
||||
connSvc *services.ConnectionService,
|
||||
settingsSvc *services.SettingsService,
|
||||
networkSvc *services.NetworkService,
|
||||
profileSvc *services.ProfileService,
|
||||
) *trayManager {
|
||||
return &trayManager{
|
||||
app: app,
|
||||
window: window,
|
||||
connSvc: connSvc,
|
||||
settingsSvc: settingsSvc,
|
||||
networkSvc: networkSvc,
|
||||
profileSvc: profileSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// Setup creates and attaches the system tray.
|
||||
func (t *trayManager) Setup(icon []byte) {
|
||||
t.tray = t.app.SystemTray.New()
|
||||
t.tray.SetIcon(icon)
|
||||
|
||||
t.menu = t.buildMenu()
|
||||
t.tray.AttachWindow(t.window).WindowOffset(5).SetMenu(t.menu)
|
||||
|
||||
// Load initial toggle states from config.
|
||||
go t.refreshToggleStates()
|
||||
|
||||
// Start status polling goroutine.
|
||||
go t.pollStatus(context.Background())
|
||||
}
|
||||
|
||||
func (t *trayManager) buildMenu() *application.Menu {
|
||||
menu := t.app.NewMenu()
|
||||
|
||||
// Status label (disabled, informational).
|
||||
t.statusItem = menu.Add("Status: Disconnected")
|
||||
t.statusItem.SetEnabled(false)
|
||||
menu.AddSeparator()
|
||||
|
||||
// Connect / Disconnect.
|
||||
menu.Add("Connect").OnClick(func(_ *application.Context) {
|
||||
go func() {
|
||||
if err := t.connSvc.Connect(); err != nil {
|
||||
log.Errorf("connect: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
menu.Add("Disconnect").OnClick(func(_ *application.Context) {
|
||||
go func() {
|
||||
if err := t.connSvc.Disconnect(); err != nil {
|
||||
log.Errorf("disconnect: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
menu.AddSeparator()
|
||||
|
||||
// Toggle checkboxes.
|
||||
t.sshItem = menu.AddCheckbox("Allow SSH connections", false)
|
||||
t.sshItem.OnClick(func(ctx *application.Context) {
|
||||
enabled := ctx.ClickedMenuItem().Checked()
|
||||
go func() {
|
||||
if err := t.settingsSvc.ToggleSSH(enabled); err != nil {
|
||||
log.Errorf("toggle SSH: %v", err)
|
||||
t.sshItem.SetChecked(!enabled)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
t.autoConnectItem = menu.AddCheckbox("Connect automatically when service starts", false)
|
||||
t.autoConnectItem.OnClick(func(ctx *application.Context) {
|
||||
enabled := ctx.ClickedMenuItem().Checked()
|
||||
go func() {
|
||||
if err := t.settingsSvc.ToggleAutoConnect(enabled); err != nil {
|
||||
log.Errorf("toggle auto-connect: %v", err)
|
||||
t.autoConnectItem.SetChecked(!enabled)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
t.rosenpassItem = menu.AddCheckbox("Enable post-quantum security via Rosenpass", false)
|
||||
t.rosenpassItem.OnClick(func(ctx *application.Context) {
|
||||
enabled := ctx.ClickedMenuItem().Checked()
|
||||
go func() {
|
||||
if err := t.settingsSvc.ToggleRosenpass(enabled); err != nil {
|
||||
log.Errorf("toggle Rosenpass: %v", err)
|
||||
t.rosenpassItem.SetChecked(!enabled)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
t.lazyConnItem = menu.AddCheckbox("[Experimental] Enable lazy connections", false)
|
||||
t.lazyConnItem.OnClick(func(ctx *application.Context) {
|
||||
enabled := ctx.ClickedMenuItem().Checked()
|
||||
go func() {
|
||||
if err := t.settingsSvc.ToggleLazyConn(enabled); err != nil {
|
||||
log.Errorf("toggle lazy connections: %v", err)
|
||||
t.lazyConnItem.SetChecked(!enabled)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
t.blockInboundItem = menu.AddCheckbox("Block inbound connections", false)
|
||||
t.blockInboundItem.OnClick(func(ctx *application.Context) {
|
||||
enabled := ctx.ClickedMenuItem().Checked()
|
||||
go func() {
|
||||
if err := t.settingsSvc.ToggleBlockInbound(enabled); err != nil {
|
||||
log.Errorf("toggle block inbound: %v", err)
|
||||
t.blockInboundItem.SetChecked(!enabled)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
t.notificationsItem = menu.AddCheckbox("Enable notifications", true)
|
||||
t.notificationsItem.OnClick(func(ctx *application.Context) {
|
||||
enabled := ctx.ClickedMenuItem().Checked()
|
||||
go func() {
|
||||
if err := t.settingsSvc.ToggleNotifications(enabled); err != nil {
|
||||
log.Errorf("toggle notifications: %v", err)
|
||||
t.notificationsItem.SetChecked(!enabled)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
menu.AddSeparator()
|
||||
|
||||
// Exit Node submenu.
|
||||
t.exitNodeMenu = menu.AddSubmenu("Exit Node")
|
||||
t.exitNodeMenu.Add("No exit nodes").SetEnabled(false)
|
||||
|
||||
menu.AddSeparator()
|
||||
|
||||
// Navigation items — navigate React SPA.
|
||||
menu.Add("Status").OnClick(func(_ *application.Context) {
|
||||
t.window.EmitEvent("navigate", "/")
|
||||
t.window.Show()
|
||||
})
|
||||
menu.Add("Settings").OnClick(func(_ *application.Context) {
|
||||
t.window.EmitEvent("navigate", "/settings")
|
||||
t.window.Show()
|
||||
})
|
||||
menu.Add("Peers").OnClick(func(_ *application.Context) {
|
||||
t.window.EmitEvent("navigate", "/peers")
|
||||
t.window.Show()
|
||||
})
|
||||
menu.Add("Networks").OnClick(func(_ *application.Context) {
|
||||
t.window.EmitEvent("navigate", "/networks")
|
||||
t.window.Show()
|
||||
})
|
||||
menu.Add("Profiles").OnClick(func(_ *application.Context) {
|
||||
t.window.EmitEvent("navigate", "/profiles")
|
||||
t.window.Show()
|
||||
})
|
||||
menu.Add("Debug").OnClick(func(_ *application.Context) {
|
||||
t.window.EmitEvent("navigate", "/debug")
|
||||
t.window.Show()
|
||||
})
|
||||
menu.Add("Update").OnClick(func(_ *application.Context) {
|
||||
t.window.EmitEvent("navigate", "/update")
|
||||
t.window.Show()
|
||||
})
|
||||
|
||||
menu.AddSeparator()
|
||||
|
||||
menu.Add("Quit").OnClick(func(_ *application.Context) {
|
||||
t.app.Quit()
|
||||
})
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
// pollStatus polls the daemon status every statusPollInterval and updates the tray.
|
||||
// Exit nodes are refreshed every 10 cycles (~20 seconds).
|
||||
func (t *trayManager) pollStatus(ctx context.Context) {
|
||||
ticker := time.NewTicker(statusPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
var cycle int
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
status, err := t.connSvc.GetStatus()
|
||||
if err != nil {
|
||||
log.Warnf("pollStatus: failed to get status: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Debugf("pollStatus: status=%q ip=%q fqdn=%q peers=%d",
|
||||
status.Status, status.IP, status.Fqdn, status.ConnectedPeers)
|
||||
t.updateStatus(status)
|
||||
|
||||
cycle++
|
||||
if cycle%10 == 0 {
|
||||
go t.refreshExitNodes()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *trayManager) updateStatus(status *services.StatusInfo) {
|
||||
label := fmt.Sprintf("Status: %s", status.Status)
|
||||
if status.IP != "" {
|
||||
label += fmt.Sprintf(" (%s)", status.IP)
|
||||
}
|
||||
t.statusItem.SetLabel(label)
|
||||
t.menu.Update()
|
||||
|
||||
// Update tray icon based on status.
|
||||
icon := iconForStatus(status.Status)
|
||||
if icon != nil {
|
||||
t.tray.SetIcon(icon)
|
||||
}
|
||||
|
||||
// Emit event so the React frontend can update live.
|
||||
log.Debugf("updateStatus: emitting status-changed event: status=%q ip=%q", status.Status, status.IP)
|
||||
t.window.EmitEvent("status-changed", status)
|
||||
}
|
||||
|
||||
func (t *trayManager) refreshToggleStates() {
|
||||
cfg, err := t.settingsSvc.GetConfig()
|
||||
if err != nil {
|
||||
log.Debugf("refresh toggle states: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.sshItem.SetChecked(cfg.ServerSSHAllowed)
|
||||
t.autoConnectItem.SetChecked(!cfg.DisableAutoConnect)
|
||||
t.rosenpassItem.SetChecked(cfg.RosenpassEnabled)
|
||||
t.lazyConnItem.SetChecked(cfg.LazyConnectionEnabled)
|
||||
t.blockInboundItem.SetChecked(cfg.BlockInbound)
|
||||
t.notificationsItem.SetChecked(!cfg.DisableNotifications)
|
||||
t.menu.Update()
|
||||
}
|
||||
|
||||
func (t *trayManager) refreshExitNodes() {
|
||||
exitNodes, err := t.networkSvc.ListExitNodes()
|
||||
if err != nil {
|
||||
log.Debugf("refresh exit nodes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
t.rebuildExitNodeMenu(exitNodes)
|
||||
}
|
||||
|
||||
func (t *trayManager) rebuildExitNodeMenu(exitNodes []services.NetworkInfo) {
|
||||
// Sort exit nodes by ID for stable ordering.
|
||||
sort.Slice(exitNodes, func(i, j int) bool {
|
||||
return exitNodes[i].ID < exitNodes[j].ID
|
||||
})
|
||||
|
||||
// Check if state has changed.
|
||||
newStates := make([]exitNodeState, 0, len(exitNodes))
|
||||
for _, n := range exitNodes {
|
||||
newStates = append(newStates, exitNodeState{id: n.ID, selected: n.Selected})
|
||||
}
|
||||
|
||||
if statesEqual(t.exitNodeStates, newStates) {
|
||||
return
|
||||
}
|
||||
t.exitNodeStates = newStates
|
||||
|
||||
// Rebuild the exit node submenu from scratch.
|
||||
// Wails v3 doesn't have a RemoveAll, so we recreate the submenu reference.
|
||||
for _, item := range t.exitNodeItems {
|
||||
item.SetHidden(true)
|
||||
}
|
||||
t.exitNodeItems = nil
|
||||
|
||||
if len(exitNodes) == 0 {
|
||||
t.menu.Update()
|
||||
return
|
||||
}
|
||||
|
||||
var hasSelected bool
|
||||
for _, node := range exitNodes {
|
||||
n := node // capture
|
||||
item := t.exitNodeMenu.AddCheckbox(n.ID, n.Selected)
|
||||
item.OnClick(func(_ *application.Context) {
|
||||
go t.toggleExitNode(n.ID)
|
||||
})
|
||||
t.exitNodeItems = append(t.exitNodeItems, item)
|
||||
if n.Selected {
|
||||
hasSelected = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasSelected {
|
||||
t.exitNodeMenu.AddSeparator()
|
||||
deselectAll := t.exitNodeMenu.Add("Deselect All")
|
||||
deselectAll.OnClick(func(_ *application.Context) {
|
||||
go t.deselectAllExitNodes()
|
||||
})
|
||||
t.exitNodeItems = append(t.exitNodeItems, deselectAll)
|
||||
}
|
||||
|
||||
t.menu.Update()
|
||||
}
|
||||
|
||||
func (t *trayManager) toggleExitNode(id string) {
|
||||
exitNodes, err := t.networkSvc.ListExitNodes()
|
||||
if err != nil {
|
||||
log.Errorf("list exit nodes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var target *services.NetworkInfo
|
||||
var selectedOtherIDs []string
|
||||
|
||||
for i, n := range exitNodes {
|
||||
if n.ID == id {
|
||||
cp := exitNodes[i]
|
||||
target = &cp
|
||||
} else if n.Selected {
|
||||
selectedOtherIDs = append(selectedOtherIDs, n.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Deselect all other selected exit nodes.
|
||||
if len(selectedOtherIDs) > 0 {
|
||||
if err := t.networkSvc.DeselectNetworks(selectedOtherIDs); err != nil {
|
||||
log.Errorf("deselect exit nodes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if target != nil && !target.Selected {
|
||||
if err := t.networkSvc.SelectNetwork(id); err != nil {
|
||||
log.Errorf("select exit node: %v", err)
|
||||
}
|
||||
} else if target != nil && target.Selected && len(selectedOtherIDs) == 0 {
|
||||
// Node is the only selected one — deselect it.
|
||||
if err := t.networkSvc.DeselectNetwork(id); err != nil {
|
||||
log.Errorf("deselect exit node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.refreshExitNodes()
|
||||
}
|
||||
|
||||
func (t *trayManager) deselectAllExitNodes() {
|
||||
exitNodes, err := t.networkSvc.ListExitNodes()
|
||||
if err != nil {
|
||||
log.Errorf("list exit nodes for deselect all: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var ids []string
|
||||
for _, n := range exitNodes {
|
||||
if n.Selected {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
if err := t.networkSvc.DeselectNetworks(ids); err != nil {
|
||||
log.Errorf("deselect all exit nodes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.refreshExitNodes()
|
||||
}
|
||||
|
||||
func statesEqual(a, b []exitNodeState) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
24
client/uiwails/tray_linux.go
Normal file
@@ -0,0 +1,24 @@
|
||||
//go:build linux && !386
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
// init runs before Wails' own init(), so the env var is set in time.
|
||||
func init() {
|
||||
if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// WebKitGTK's DMA-BUF renderer fails on many setups (VMs, containers,
|
||||
// minimal WMs without proper GPU access) and leaves the window blank
|
||||
// white. Wails only disables it for NVIDIA+Wayland, but the issue is
|
||||
// broader. Always disable it — software rendering works fine for a
|
||||
// small UI like this.
|
||||
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
|
||||
}
|
||||
|
||||
// On Linux, the system tray provider may require the menu to be recreated
|
||||
// rather than updated in place. The rebuildExitNodeMenu method in tray.go
|
||||
// already handles this by removing and re-adding items; no additional
|
||||
// Linux-specific workaround is needed for Wails v3.
|
||||
148
client/uiwails/tray_watcher_linux.go
Normal file
@@ -0,0 +1,148 @@
|
||||
//go:build linux && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
// startStatusNotifierWatcher registers org.kde.StatusNotifierWatcher on the
|
||||
// session D-Bus if no other process has already claimed it.
|
||||
//
|
||||
// Minimal window managers (Fluxbox, OpenBox, i3, etc.) do not ship a
|
||||
// StatusNotifier watcher, so tray icons using libayatana-appindicator or
|
||||
// the KDE/freedesktop StatusNotifier protocol silently fail.
|
||||
//
|
||||
// By owning the watcher name in-process we allow the Wails v3 built-in tray
|
||||
// to register itself — no external daemon or package needed.
|
||||
//
|
||||
// When an XEmbed system tray is available (_NET_SYSTEM_TRAY_S0), we also
|
||||
// start an in-process XEmbed host that bridges the SNI icon into the
|
||||
// XEmbed tray (Fluxbox, IceWM, etc.).
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
watcherName = "org.kde.StatusNotifierWatcher"
|
||||
watcherPath = "/StatusNotifierWatcher"
|
||||
watcherIface = "org.kde.StatusNotifierWatcher"
|
||||
)
|
||||
|
||||
type statusNotifierWatcher struct {
|
||||
conn *dbus.Conn
|
||||
items []string
|
||||
hosts map[string]*xembedHost
|
||||
hostsMu sync.Mutex
|
||||
}
|
||||
|
||||
// RegisterStatusNotifierItem is the D-Bus method called by tray clients.
|
||||
// The sender parameter is automatically injected by godbus with the caller's
|
||||
// unique bus name (e.g. ":1.42"). It does not appear in the D-Bus signature.
|
||||
func (w *statusNotifierWatcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error {
|
||||
for _, s := range w.items {
|
||||
if s == service {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
w.items = append(w.items, service)
|
||||
log.Debugf("StatusNotifierWatcher: registered item %q from %s", service, sender)
|
||||
|
||||
go w.tryStartXembedHost(string(sender), dbus.ObjectPath(service))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterStatusNotifierHost is required by the protocol but unused here.
|
||||
func (w *statusNotifierWatcher) RegisterStatusNotifierHost(service string) *dbus.Error {
|
||||
log.Debugf("StatusNotifierWatcher: host registered %q", service)
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryStartXembedHost attempts to create an XEmbed tray icon for the given
|
||||
// SNI item. If no XEmbed tray manager is available, this is a no-op.
|
||||
func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus.ObjectPath) {
|
||||
w.hostsMu.Lock()
|
||||
defer w.hostsMu.Unlock()
|
||||
|
||||
if _, exists := w.hosts[busName]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
// Use a private session bus so our signal subscriptions don't
|
||||
// interfere with Wails' signal handler (which panics on unexpected signals).
|
||||
sessionConn, err := dbus.SessionBusPrivate()
|
||||
if err != nil {
|
||||
log.Debugf("StatusNotifierWatcher: cannot open private session bus for XEmbed host: %v", err)
|
||||
return
|
||||
}
|
||||
if err := sessionConn.Auth(nil); err != nil {
|
||||
log.Debugf("StatusNotifierWatcher: XEmbed host auth failed: %v", err)
|
||||
_ = sessionConn.Close()
|
||||
return
|
||||
}
|
||||
if err := sessionConn.Hello(); err != nil {
|
||||
log.Debugf("StatusNotifierWatcher: XEmbed host Hello failed: %v", err)
|
||||
_ = sessionConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
host, err := newXembedHost(sessionConn, busName, objPath)
|
||||
if err != nil {
|
||||
log.Debugf("StatusNotifierWatcher: XEmbed host not started: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.hosts[busName] = host
|
||||
go host.run()
|
||||
log.Infof("StatusNotifierWatcher: XEmbed tray icon created for %s", busName)
|
||||
}
|
||||
|
||||
// startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher on the
|
||||
// session bus if it is not already provided by another process.
|
||||
// Safe to call unconditionally — it does nothing when a real watcher is present.
|
||||
func startStatusNotifierWatcher() {
|
||||
conn, err := dbus.SessionBusPrivate()
|
||||
if err != nil {
|
||||
log.Debugf("StatusNotifierWatcher: cannot open private session bus: %v", err)
|
||||
return
|
||||
}
|
||||
if err := conn.Auth(nil); err != nil {
|
||||
log.Debugf("StatusNotifierWatcher: auth failed: %v", err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if err := conn.Hello(); err != nil {
|
||||
log.Debugf("StatusNotifierWatcher: Hello failed: %v", err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Check whether another process already owns the watcher name.
|
||||
var owner string
|
||||
callErr := conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, watcherName).Store(&owner)
|
||||
if callErr == nil && owner != "" {
|
||||
log.Debugf("StatusNotifierWatcher: already owned by %s, skipping", owner)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := conn.RequestName(watcherName, dbus.NameFlagDoNotQueue)
|
||||
if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
|
||||
log.Debugf("StatusNotifierWatcher: could not claim name (reply=%v err=%v)", reply, err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
w := &statusNotifierWatcher{
|
||||
conn: conn,
|
||||
hosts: make(map[string]*xembedHost),
|
||||
}
|
||||
if err := conn.ExportAll(w, dbus.ObjectPath(watcherPath), watcherIface); err != nil {
|
||||
log.Errorf("StatusNotifierWatcher: export failed: %v", err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("StatusNotifierWatcher: active on session bus (enables tray on minimal WMs)")
|
||||
// Connection intentionally kept open for the lifetime of the process.
|
||||
}
|
||||
6
client/uiwails/tray_watcher_other.go
Normal file
@@ -0,0 +1,6 @@
|
||||
//go:build !linux || (linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
// startStatusNotifierWatcher is a no-op on non-Linux platforms.
|
||||
func startStatusNotifierWatcher() {}
|
||||
BIN
client/uiwails/uiwails
Executable file
13
client/uiwails/util.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultContext returns a context with the given timeout.
|
||||
func defaultContext(timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), timeout)
|
||||
}
|
||||
8
client/uiwails/wails.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "netbird-ui",
|
||||
"outputfilename": "netbird-ui",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverurl": "auto"
|
||||
}
|
||||
393
client/uiwails/xembed_host_linux.go
Normal file
@@ -0,0 +1,393 @@
|
||||
//go:build linux && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo pkg-config: x11 gtk+-3.0
|
||||
#cgo LDFLAGS: -lX11
|
||||
#include "xembed_tray.h"
|
||||
#include <X11/Xlib.h>
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// activeMenuHost is the xembedHost that currently owns the popup menu.
|
||||
// This is needed because C callbacks cannot carry Go pointers.
|
||||
var (
|
||||
activeMenuHost *xembedHost
|
||||
activeMenuHostMu sync.Mutex
|
||||
)
|
||||
|
||||
//export goMenuItemClicked
|
||||
func goMenuItemClicked(id C.int) {
|
||||
activeMenuHostMu.Lock()
|
||||
h := activeMenuHost
|
||||
activeMenuHostMu.Unlock()
|
||||
|
||||
if h != nil {
|
||||
go h.sendMenuEvent(int32(id))
|
||||
}
|
||||
}
|
||||
|
||||
// xembedHost manages one XEmbed tray icon for an SNI item.
|
||||
type xembedHost struct {
|
||||
conn *dbus.Conn
|
||||
busName string
|
||||
objPath dbus.ObjectPath
|
||||
|
||||
dpy *C.Display
|
||||
trayMgr C.Window
|
||||
iconWin C.Window
|
||||
iconSize int
|
||||
|
||||
mu sync.Mutex
|
||||
iconData []byte
|
||||
iconW int
|
||||
iconH int
|
||||
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// newXembedHost creates an XEmbed tray icon for the given SNI item.
|
||||
// Returns an error if no XEmbed tray manager is available (graceful fallback).
|
||||
func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) {
|
||||
dpy := C.XOpenDisplay(nil)
|
||||
if dpy == nil {
|
||||
return nil, errors.New("cannot open X display")
|
||||
}
|
||||
|
||||
screen := C.xembed_default_screen(dpy)
|
||||
trayMgr := C.xembed_find_tray(dpy, screen)
|
||||
if trayMgr == 0 {
|
||||
C.XCloseDisplay(dpy)
|
||||
return nil, errors.New("no XEmbed system tray found")
|
||||
}
|
||||
|
||||
// Query the tray manager's preferred icon size.
|
||||
iconSize := int(C.xembed_get_icon_size(dpy, trayMgr))
|
||||
if iconSize <= 0 {
|
||||
iconSize = 24 // fallback
|
||||
}
|
||||
|
||||
iconWin := C.xembed_create_icon(dpy, screen, C.int(iconSize), trayMgr)
|
||||
if iconWin == 0 {
|
||||
C.XCloseDisplay(dpy)
|
||||
return nil, errors.New("failed to create icon window")
|
||||
}
|
||||
|
||||
if C.xembed_dock(dpy, trayMgr, iconWin) != 0 {
|
||||
C.xembed_destroy_icon(dpy, iconWin)
|
||||
C.XCloseDisplay(dpy)
|
||||
return nil, errors.New("failed to dock icon")
|
||||
}
|
||||
|
||||
h := &xembedHost{
|
||||
conn: conn,
|
||||
busName: busName,
|
||||
objPath: objPath,
|
||||
dpy: dpy,
|
||||
trayMgr: trayMgr,
|
||||
iconWin: iconWin,
|
||||
iconSize: iconSize,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
h.fetchAndDrawIcon()
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// fetchAndDrawIcon reads IconPixmap from the SNI item via D-Bus and draws it.
|
||||
func (h *xembedHost) fetchAndDrawIcon() {
|
||||
obj := h.conn.Object(h.busName, h.objPath)
|
||||
variant, err := obj.GetProperty("org.kde.StatusNotifierItem.IconPixmap")
|
||||
if err != nil {
|
||||
log.Debugf("xembed: failed to get IconPixmap: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// IconPixmap is []struct{W, H int32; Pix []byte} on D-Bus,
|
||||
// represented as a(iiay) signature.
|
||||
type px struct {
|
||||
W int32
|
||||
H int32
|
||||
Pix []byte
|
||||
}
|
||||
|
||||
var icons []px
|
||||
if err := variant.Store(&icons); err != nil {
|
||||
log.Debugf("xembed: failed to decode IconPixmap: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(icons) == 0 {
|
||||
log.Debug("xembed: IconPixmap is empty")
|
||||
return
|
||||
}
|
||||
|
||||
icon := icons[0]
|
||||
if icon.W <= 0 || icon.H <= 0 || len(icon.Pix) < int(icon.W*icon.H*4) {
|
||||
log.Debug("xembed: invalid IconPixmap data")
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.iconData = icon.Pix
|
||||
h.iconW = int(icon.W)
|
||||
h.iconH = int(icon.H)
|
||||
h.mu.Unlock()
|
||||
|
||||
h.drawIcon()
|
||||
}
|
||||
|
||||
// drawIcon draws the cached icon data onto the X11 window.
|
||||
func (h *xembedHost) drawIcon() {
|
||||
h.mu.Lock()
|
||||
data := h.iconData
|
||||
w := h.iconW
|
||||
ht := h.iconH
|
||||
h.mu.Unlock()
|
||||
|
||||
if data == nil || w <= 0 || ht <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cData := C.CBytes(data)
|
||||
defer C.free(cData)
|
||||
|
||||
C.xembed_draw_icon(h.dpy, h.iconWin, C.int(h.iconSize),
|
||||
(*C.uchar)(cData), C.int(w), C.int(ht))
|
||||
}
|
||||
|
||||
// run is the main event loop. It polls X11 events and listens for D-Bus
|
||||
// NewIcon signals to keep the tray icon updated.
|
||||
func (h *xembedHost) run() {
|
||||
// Subscribe to NewIcon signals from the SNI item.
|
||||
matchRule := "type='signal',interface='org.kde.StatusNotifierItem',member='NewIcon',sender='" + h.busName + "'"
|
||||
if err := h.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
|
||||
log.Debugf("xembed: failed to add signal match: %v", err)
|
||||
}
|
||||
|
||||
sigCh := make(chan *dbus.Signal, 16)
|
||||
h.conn.Signal(sigCh)
|
||||
defer h.conn.RemoveSignal(sigCh)
|
||||
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return
|
||||
|
||||
case sig := <-sigCh:
|
||||
if sig == nil {
|
||||
continue
|
||||
}
|
||||
if sig.Name == "org.kde.StatusNotifierItem.NewIcon" {
|
||||
h.fetchAndDrawIcon()
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
var outX, outY C.int
|
||||
result := C.xembed_poll_event(h.dpy, h.iconWin, &outX, &outY)
|
||||
|
||||
switch result {
|
||||
case 1: // left click
|
||||
go h.activate(int32(outX), int32(outY))
|
||||
case 2: // right click
|
||||
log.Infof("xembed: right-click at (%d, %d)", int(outX), int(outY))
|
||||
go h.contextMenu(int32(outX), int32(outY))
|
||||
case 3: // expose
|
||||
h.drawIcon()
|
||||
case 4: // configure (resize)
|
||||
newSize := int(outX)
|
||||
if newSize > 0 && newSize != h.iconSize {
|
||||
h.iconSize = newSize
|
||||
h.drawIcon()
|
||||
}
|
||||
case -1: // tray died
|
||||
log.Info("xembed: tray manager destroyed, cleaning up")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *xembedHost) activate(x, y int32) {
|
||||
obj := h.conn.Object(h.busName, h.objPath)
|
||||
if err := obj.Call("org.kde.StatusNotifierItem.Activate", 0, x, y).Err; err != nil {
|
||||
log.Debugf("xembed: Activate call failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *xembedHost) contextMenu(x, y int32) {
|
||||
// Read the menu path from the SNI item's Menu property.
|
||||
menuPath := dbus.ObjectPath("/StatusNotifierMenu")
|
||||
|
||||
// Fetch menu layout from com.canonical.dbusmenu.
|
||||
menuObj := h.conn.Object(h.busName, menuPath)
|
||||
var revision uint32
|
||||
var layout dbusMenuLayout
|
||||
err := menuObj.Call("com.canonical.dbusmenu.GetLayout", 0,
|
||||
int32(0), // parentId (root)
|
||||
int32(-1), // recursionDepth (all)
|
||||
[]string{}, // propertyNames (all)
|
||||
).Store(&revision, &layout)
|
||||
if err != nil {
|
||||
log.Debugf("xembed: GetLayout failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
items := h.flattenMenu(layout)
|
||||
log.Infof("xembed: menu has %d items (revision %d)", len(items), revision)
|
||||
for i, mi := range items {
|
||||
log.Infof("xembed: menu[%d] id=%d label=%q sep=%v check=%v", i, mi.id, mi.label, mi.isSeparator, mi.isCheck)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Build C menu item array.
|
||||
cItems := make([]C.xembed_menu_item, len(items))
|
||||
cLabels := make([]*C.char, len(items)) // track for freeing
|
||||
for i, mi := range items {
|
||||
cItems[i].id = C.int(mi.id)
|
||||
cItems[i].enabled = boolToInt(mi.enabled)
|
||||
cItems[i].is_check = boolToInt(mi.isCheck)
|
||||
cItems[i].checked = boolToInt(mi.checked)
|
||||
cItems[i].is_separator = boolToInt(mi.isSeparator)
|
||||
if mi.label != "" {
|
||||
cLabels[i] = C.CString(mi.label)
|
||||
cItems[i].label = cLabels[i]
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
for _, p := range cLabels {
|
||||
if p != nil {
|
||||
C.free(unsafe.Pointer(p))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the active menu host so the C callback can reach us.
|
||||
activeMenuHostMu.Lock()
|
||||
activeMenuHost = h
|
||||
activeMenuHostMu.Unlock()
|
||||
|
||||
C.xembed_show_popup_menu(&cItems[0], C.int(len(cItems)),
|
||||
nil, C.int(x), C.int(y))
|
||||
}
|
||||
|
||||
// dbusMenuLayout represents a com.canonical.dbusmenu layout item.
|
||||
type dbusMenuLayout struct {
|
||||
ID int32
|
||||
Properties map[string]dbus.Variant
|
||||
Children []dbus.Variant
|
||||
}
|
||||
|
||||
type menuItemInfo struct {
|
||||
id int32
|
||||
label string
|
||||
enabled bool
|
||||
isCheck bool
|
||||
checked bool
|
||||
isSeparator bool
|
||||
}
|
||||
|
||||
func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo {
|
||||
var items []menuItemInfo
|
||||
|
||||
for _, childVar := range layout.Children {
|
||||
var child dbusMenuLayout
|
||||
if err := dbus.Store([]interface{}{childVar.Value()}, &child); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
mi := menuItemInfo{
|
||||
id: child.ID,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
if v, ok := child.Properties["type"]; ok {
|
||||
if s, ok := v.Value().(string); ok && s == "separator" {
|
||||
mi.isSeparator = true
|
||||
items = append(items, mi)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := child.Properties["label"]; ok {
|
||||
if s, ok := v.Value().(string); ok {
|
||||
mi.label = s
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := child.Properties["enabled"]; ok {
|
||||
if b, ok := v.Value().(bool); ok {
|
||||
mi.enabled = b
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := child.Properties["visible"]; ok {
|
||||
if b, ok := v.Value().(bool); ok && !b {
|
||||
continue // skip hidden items
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := child.Properties["toggle-type"]; ok {
|
||||
if s, ok := v.Value().(string); ok && s == "checkmark" {
|
||||
mi.isCheck = true
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := child.Properties["toggle-state"]; ok {
|
||||
if n, ok := v.Value().(int32); ok && n == 1 {
|
||||
mi.checked = true
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, mi)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func (h *xembedHost) sendMenuEvent(id int32) {
|
||||
menuPath := dbus.ObjectPath("/StatusNotifierMenu")
|
||||
menuObj := h.conn.Object(h.busName, menuPath)
|
||||
data := dbus.MakeVariant("")
|
||||
err := menuObj.Call("com.canonical.dbusmenu.Event", 0,
|
||||
id, "clicked", data, uint32(0)).Err
|
||||
if err != nil {
|
||||
log.Debugf("xembed: menu Event call failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func boolToInt(b bool) C.int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (h *xembedHost) stop() {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return // already stopped
|
||||
default:
|
||||
close(h.stopCh)
|
||||
}
|
||||
|
||||
C.xembed_destroy_icon(h.dpy, h.iconWin)
|
||||
C.XCloseDisplay(h.dpy)
|
||||
}
|
||||
18
client/uiwails/xembed_host_other.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build !linux || (linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
type xembedHost struct{}
|
||||
|
||||
func newXembedHost(_ *dbus.Conn, _ string, _ dbus.ObjectPath) (*xembedHost, error) {
|
||||
return nil, errors.New("XEmbed tray not supported on this platform")
|
||||
}
|
||||
|
||||
func (h *xembedHost) run() {}
|
||||
func (h *xembedHost) stop() {}
|
||||
428
client/uiwails/xembed_tray.c
Normal file
@@ -0,0 +1,428 @@
|
||||
#include "xembed_tray.h"
|
||||
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/Xutil.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#define SYSTEM_TRAY_REQUEST_DOCK 0
|
||||
#define XEMBED_MAPPED (1 << 0)
|
||||
|
||||
Window xembed_find_tray(Display *dpy, int screen) {
|
||||
char atom_name[64];
|
||||
snprintf(atom_name, sizeof(atom_name), "_NET_SYSTEM_TRAY_S%d", screen);
|
||||
Atom sel = XInternAtom(dpy, atom_name, False);
|
||||
return XGetSelectionOwner(dpy, sel);
|
||||
}
|
||||
|
||||
int xembed_get_icon_size(Display *dpy, Window tray_mgr) {
|
||||
Atom atom = XInternAtom(dpy, "_NET_SYSTEM_TRAY_ICON_SIZE", False);
|
||||
Atom actual_type;
|
||||
int actual_format;
|
||||
unsigned long nitems, bytes_after;
|
||||
unsigned char *prop = NULL;
|
||||
int size = 0;
|
||||
|
||||
if (XGetWindowProperty(dpy, tray_mgr, atom, 0, 1, False,
|
||||
XA_CARDINAL, &actual_type, &actual_format,
|
||||
&nitems, &bytes_after, &prop) == Success) {
|
||||
if (prop && nitems == 1 && actual_format == 32) {
|
||||
size = (int)(*(unsigned long *)prop);
|
||||
}
|
||||
if (prop)
|
||||
XFree(prop);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
/* Find a 32-bit TrueColor ARGB visual on the given screen. */
|
||||
static Visual *find_argb_visual(Display *dpy, int screen, int *out_depth) {
|
||||
XVisualInfo tmpl;
|
||||
tmpl.screen = screen;
|
||||
tmpl.depth = 32;
|
||||
tmpl.class = TrueColor;
|
||||
int ninfo = 0;
|
||||
XVisualInfo *vi = XGetVisualInfo(dpy,
|
||||
VisualScreenMask | VisualDepthMask | VisualClassMask,
|
||||
&tmpl, &ninfo);
|
||||
if (!vi || ninfo == 0)
|
||||
return NULL;
|
||||
|
||||
Visual *vis = vi[0].visual;
|
||||
*out_depth = vi[0].depth;
|
||||
XFree(vi);
|
||||
return vis;
|
||||
}
|
||||
|
||||
/* Try to get a 32-bit ARGB visual for the tray icon.
|
||||
First checks _NET_SYSTEM_TRAY_VISUAL on the tray manager window;
|
||||
if not set, searches for any 32-bit TrueColor visual on the screen. */
|
||||
static Visual *get_tray_visual(Display *dpy, int screen, Window tray_mgr,
|
||||
int *out_depth) {
|
||||
Atom atom = XInternAtom(dpy, "_NET_SYSTEM_TRAY_VISUAL", False);
|
||||
Atom actual_type;
|
||||
int actual_format;
|
||||
unsigned long nitems, bytes_after;
|
||||
unsigned char *prop = NULL;
|
||||
|
||||
if (XGetWindowProperty(dpy, tray_mgr, atom, 0, 1, False,
|
||||
XA_VISUALID, &actual_type, &actual_format,
|
||||
&nitems, &bytes_after, &prop) == Success &&
|
||||
prop && nitems == 1) {
|
||||
VisualID vid = (VisualID)(*(unsigned long *)prop);
|
||||
XFree(prop);
|
||||
|
||||
/* Look up the visual by ID. */
|
||||
XVisualInfo tmpl;
|
||||
tmpl.visualid = vid;
|
||||
tmpl.screen = screen;
|
||||
int ninfo = 0;
|
||||
XVisualInfo *vi = XGetVisualInfo(dpy,
|
||||
VisualIDMask | VisualScreenMask, &tmpl, &ninfo);
|
||||
if (vi && ninfo > 0) {
|
||||
Visual *vis = vi[0].visual;
|
||||
*out_depth = vi[0].depth;
|
||||
XFree(vi);
|
||||
return vis;
|
||||
}
|
||||
} else {
|
||||
if (prop) XFree(prop);
|
||||
}
|
||||
|
||||
/* Tray didn't advertise a visual — find one ourselves. */
|
||||
return find_argb_visual(dpy, screen, out_depth);
|
||||
}
|
||||
|
||||
Window xembed_create_icon(Display *dpy, int screen, int size,
|
||||
Window tray_mgr) {
|
||||
Window root = RootWindow(dpy, screen);
|
||||
|
||||
/* Try to use the tray's advertised ARGB visual for transparency. */
|
||||
int depth = 0;
|
||||
Visual *vis = get_tray_visual(dpy, screen, tray_mgr, &depth);
|
||||
|
||||
XSetWindowAttributes attrs;
|
||||
memset(&attrs, 0, sizeof(attrs));
|
||||
attrs.event_mask = ButtonPressMask | StructureNotifyMask | ExposureMask;
|
||||
unsigned long mask = CWEventMask;
|
||||
|
||||
if (vis && depth == 32) {
|
||||
/* 32-bit visual: create our own colormap and set a transparent bg. */
|
||||
attrs.colormap = XCreateColormap(dpy, root, vis, AllocNone);
|
||||
attrs.background_pixel = 0; /* fully transparent */
|
||||
attrs.border_pixel = 0;
|
||||
mask |= CWColormap | CWBackPixel | CWBorderPixel;
|
||||
} else {
|
||||
/* Fallback: use default visual. */
|
||||
vis = CopyFromParent;
|
||||
depth = CopyFromParent;
|
||||
attrs.background_pixel = 0;
|
||||
mask |= CWBackPixel;
|
||||
}
|
||||
|
||||
Window win = XCreateWindow(
|
||||
dpy, root,
|
||||
0, 0, size, size,
|
||||
0, /* border width */
|
||||
depth,
|
||||
InputOutput,
|
||||
vis,
|
||||
mask,
|
||||
&attrs
|
||||
);
|
||||
|
||||
/* Set _XEMBED_INFO: version=0, flags=XEMBED_MAPPED */
|
||||
Atom xembed_info = XInternAtom(dpy, "_XEMBED_INFO", False);
|
||||
unsigned long info[2] = { 0, XEMBED_MAPPED };
|
||||
XChangeProperty(dpy, win, xembed_info, xembed_info,
|
||||
32, PropModeReplace, (unsigned char *)info, 2);
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win) {
|
||||
Atom opcode = XInternAtom(dpy, "_NET_SYSTEM_TRAY_OPCODE", False);
|
||||
|
||||
XClientMessageEvent ev;
|
||||
memset(&ev, 0, sizeof(ev));
|
||||
ev.type = ClientMessage;
|
||||
ev.window = tray_mgr;
|
||||
ev.message_type = opcode;
|
||||
ev.format = 32;
|
||||
ev.data.l[0] = CurrentTime;
|
||||
ev.data.l[1] = SYSTEM_TRAY_REQUEST_DOCK;
|
||||
ev.data.l[2] = (long)icon_win;
|
||||
|
||||
XSendEvent(dpy, tray_mgr, False, NoEventMask, (XEvent *)&ev);
|
||||
XFlush(dpy);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void xembed_draw_icon(Display *dpy, Window icon_win, int win_size,
|
||||
const unsigned char *data, int img_w, int img_h) {
|
||||
if (!data || img_w <= 0 || img_h <= 0 || win_size <= 0)
|
||||
return;
|
||||
|
||||
/* Query the window's actual visual and depth so we draw correctly
|
||||
even when using a 32-bit ARGB visual for transparency. */
|
||||
XWindowAttributes wa;
|
||||
if (!XGetWindowAttributes(dpy, icon_win, &wa))
|
||||
return;
|
||||
|
||||
int depth = wa.depth;
|
||||
Visual *vis = wa.visual;
|
||||
|
||||
/* Clear the window to transparent before drawing. */
|
||||
XClearWindow(dpy, icon_win);
|
||||
|
||||
/* Allocate buffer for the scaled image in native X11 format (32bpp). */
|
||||
unsigned int *buf = (unsigned int *)calloc(win_size * win_size,
|
||||
sizeof(unsigned int));
|
||||
if (!buf)
|
||||
return;
|
||||
|
||||
/* Nearest-neighbor scale from source ARGB [A,R,G,B] bytes to native uint32. */
|
||||
int x, y;
|
||||
for (y = 0; y < win_size; y++) {
|
||||
int src_y = y * img_h / win_size;
|
||||
if (src_y >= img_h) src_y = img_h - 1;
|
||||
for (x = 0; x < win_size; x++) {
|
||||
int src_x = x * img_w / win_size;
|
||||
if (src_x >= img_w) src_x = img_w - 1;
|
||||
|
||||
int idx = (src_y * img_w + src_x) * 4;
|
||||
unsigned char a = data[idx + 0];
|
||||
unsigned char r = data[idx + 1];
|
||||
unsigned char g = data[idx + 2];
|
||||
unsigned char b = data[idx + 3];
|
||||
|
||||
/* Pre-multiply alpha for correct compositing on 32-bit visuals. */
|
||||
if (a == 0) {
|
||||
buf[y * win_size + x] = 0;
|
||||
} else if (a == 255) {
|
||||
buf[y * win_size + x] = ((unsigned int)a << 24) |
|
||||
((unsigned int)r << 16) |
|
||||
((unsigned int)g << 8) |
|
||||
((unsigned int)b);
|
||||
} else {
|
||||
unsigned int pr = (unsigned int)r * a / 255;
|
||||
unsigned int pg = (unsigned int)g * a / 255;
|
||||
unsigned int pb = (unsigned int)b * a / 255;
|
||||
buf[y * win_size + x] = ((unsigned int)a << 24) |
|
||||
(pr << 16) | (pg << 8) | pb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XImage *img = XCreateImage(dpy, vis, depth, ZPixmap, 0,
|
||||
(char *)buf, win_size, win_size,
|
||||
32, 0);
|
||||
if (!img) {
|
||||
free(buf);
|
||||
return;
|
||||
}
|
||||
|
||||
GC gc = XCreateGC(dpy, icon_win, 0, NULL);
|
||||
XPutImage(dpy, icon_win, gc, img, 0, 0, 0, 0, win_size, win_size);
|
||||
XFreeGC(dpy, gc);
|
||||
|
||||
/* XDestroyImage frees the data pointer (buf) for us. */
|
||||
XDestroyImage(img);
|
||||
XFlush(dpy);
|
||||
}
|
||||
|
||||
void xembed_destroy_icon(Display *dpy, Window icon_win) {
|
||||
if (icon_win)
|
||||
XDestroyWindow(dpy, icon_win);
|
||||
XFlush(dpy);
|
||||
}
|
||||
|
||||
int xembed_poll_event(Display *dpy, Window icon_win,
|
||||
int *out_x, int *out_y) {
|
||||
*out_x = 0;
|
||||
*out_y = 0;
|
||||
|
||||
while (XPending(dpy) > 0) {
|
||||
XEvent ev;
|
||||
XNextEvent(dpy, &ev);
|
||||
|
||||
switch (ev.type) {
|
||||
case ButtonPress:
|
||||
if (ev.xbutton.window == icon_win) {
|
||||
*out_x = ev.xbutton.x_root;
|
||||
*out_y = ev.xbutton.y_root;
|
||||
if (ev.xbutton.button == Button1)
|
||||
return 1;
|
||||
if (ev.xbutton.button == Button3)
|
||||
return 2;
|
||||
}
|
||||
break;
|
||||
|
||||
case Expose:
|
||||
if (ev.xexpose.window == icon_win && ev.xexpose.count == 0)
|
||||
return 3;
|
||||
break;
|
||||
|
||||
case DestroyNotify:
|
||||
if (ev.xdestroywindow.window == icon_win)
|
||||
return -1;
|
||||
break;
|
||||
|
||||
case ConfigureNotify:
|
||||
if (ev.xconfigure.window == icon_win) {
|
||||
*out_x = ev.xconfigure.width;
|
||||
*out_y = ev.xconfigure.height;
|
||||
return 4;
|
||||
}
|
||||
break;
|
||||
|
||||
case ReparentNotify:
|
||||
/* Tray manager reparented us — this is expected after docking. */
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- GTK3 popup window menu support --- */
|
||||
|
||||
/* Implemented in Go via //export */
|
||||
extern void goMenuItemClicked(int id);
|
||||
|
||||
/* The popup window, reused across invocations. */
|
||||
static GtkWidget *popup_win = NULL;
|
||||
|
||||
typedef struct {
|
||||
xembed_menu_item *items;
|
||||
int count;
|
||||
int x, y;
|
||||
} popup_data;
|
||||
|
||||
static void free_popup_data(popup_data *pd) {
|
||||
if (!pd) return;
|
||||
for (int i = 0; i < pd->count; i++)
|
||||
free((void *)pd->items[i].label);
|
||||
free(pd->items);
|
||||
free(pd);
|
||||
}
|
||||
|
||||
static void on_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
int id = GPOINTER_TO_INT(user_data);
|
||||
if (popup_win)
|
||||
gtk_widget_hide(popup_win);
|
||||
goMenuItemClicked(id);
|
||||
}
|
||||
|
||||
static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) {
|
||||
int id = GPOINTER_TO_INT(user_data);
|
||||
if (popup_win)
|
||||
gtk_widget_hide(popup_win);
|
||||
goMenuItemClicked(id);
|
||||
}
|
||||
|
||||
static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event,
|
||||
gpointer user_data) {
|
||||
gtk_widget_hide(widget);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static gboolean popup_menu_idle(gpointer user_data) {
|
||||
popup_data *pd = (popup_data *)user_data;
|
||||
|
||||
/* Destroy old popup if it exists. */
|
||||
if (popup_win) {
|
||||
gtk_widget_destroy(popup_win);
|
||||
popup_win = NULL;
|
||||
}
|
||||
|
||||
popup_win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_type_hint(GTK_WINDOW(popup_win),
|
||||
GDK_WINDOW_TYPE_HINT_POPUP_MENU);
|
||||
gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE);
|
||||
gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE);
|
||||
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(popup_win), TRUE);
|
||||
gtk_window_set_skip_pager_hint(GTK_WINDOW(popup_win), TRUE);
|
||||
gtk_window_set_keep_above(GTK_WINDOW(popup_win), TRUE);
|
||||
|
||||
/* Close on focus loss. */
|
||||
g_signal_connect(popup_win, "focus-out-event",
|
||||
G_CALLBACK(on_popup_focus_out), NULL);
|
||||
|
||||
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_container_add(GTK_CONTAINER(popup_win), vbox);
|
||||
|
||||
for (int i = 0; i < pd->count; i++) {
|
||||
xembed_menu_item *mi = &pd->items[i];
|
||||
|
||||
if (mi->is_separator) {
|
||||
GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
|
||||
gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mi->is_check) {
|
||||
GtkWidget *chk = gtk_check_button_new_with_label(
|
||||
mi->label ? mi->label : "");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked);
|
||||
gtk_widget_set_sensitive(chk, mi->enabled);
|
||||
g_signal_connect(chk, "toggled",
|
||||
G_CALLBACK(on_check_toggled),
|
||||
GINT_TO_POINTER(mi->id));
|
||||
gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0);
|
||||
} else {
|
||||
GtkWidget *btn = gtk_button_new_with_label(
|
||||
mi->label ? mi->label : "");
|
||||
gtk_widget_set_sensitive(btn, mi->enabled);
|
||||
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
|
||||
/* Left-align label. */
|
||||
GtkWidget *label = gtk_bin_get_child(GTK_BIN(btn));
|
||||
if (label)
|
||||
gtk_label_set_xalign(GTK_LABEL(label), 0.0);
|
||||
g_signal_connect(btn, "clicked",
|
||||
G_CALLBACK(on_button_clicked),
|
||||
GINT_TO_POINTER(mi->id));
|
||||
gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0);
|
||||
}
|
||||
}
|
||||
|
||||
gtk_widget_show_all(popup_win);
|
||||
|
||||
/* Position the window above the click point (menu grows upward from tray). */
|
||||
gint win_w, win_h;
|
||||
gtk_window_get_size(GTK_WINDOW(popup_win), &win_w, &win_h);
|
||||
int final_x = pd->x - win_w / 2;
|
||||
int final_y = pd->y - win_h;
|
||||
if (final_x < 0) final_x = 0;
|
||||
if (final_y < 0) final_y = pd->y; /* fallback: below click */
|
||||
gtk_window_move(GTK_WINDOW(popup_win), final_x, final_y);
|
||||
|
||||
/* Grab focus so focus-out-event works. */
|
||||
gtk_window_present(GTK_WINDOW(popup_win));
|
||||
|
||||
free_popup_data(pd);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
void xembed_show_popup_menu(xembed_menu_item *items, int count,
|
||||
xembed_menu_click_cb cb, int x, int y) {
|
||||
(void)cb;
|
||||
popup_data *pd = (popup_data *)calloc(1, sizeof(popup_data));
|
||||
pd->items = (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item));
|
||||
pd->count = count;
|
||||
pd->x = x;
|
||||
pd->y = y;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
pd->items[i] = items[i];
|
||||
if (items[i].label)
|
||||
pd->items[i].label = strdup(items[i].label);
|
||||
}
|
||||
|
||||
g_idle_add(popup_menu_idle, pd);
|
||||
}
|
||||
71
client/uiwails/xembed_tray.h
Normal file
@@ -0,0 +1,71 @@
|
||||
#ifndef XEMBED_TRAY_H
|
||||
#define XEMBED_TRAY_H
|
||||
|
||||
#include <X11/Xlib.h>
|
||||
|
||||
// xembed_default_screen wraps the DefaultScreen macro for CGo.
|
||||
static inline int xembed_default_screen(Display *dpy) {
|
||||
return DefaultScreen(dpy);
|
||||
}
|
||||
|
||||
// xembed_find_tray returns the selection owner window for
|
||||
// _NET_SYSTEM_TRAY_S{screen}, or 0 if no XEmbed tray manager exists.
|
||||
Window xembed_find_tray(Display *dpy, int screen);
|
||||
|
||||
// xembed_get_icon_size queries _NET_SYSTEM_TRAY_ICON_SIZE from the tray
|
||||
// manager window. Returns the size in pixels, or 0 if not set.
|
||||
int xembed_get_icon_size(Display *dpy, Window tray_mgr);
|
||||
|
||||
// xembed_create_icon creates a tray icon window of the given size,
|
||||
// sets _XEMBED_INFO, and returns the window ID.
|
||||
// tray_mgr is the tray manager window; its _NET_SYSTEM_TRAY_VISUAL
|
||||
// property is queried to obtain a 32-bit ARGB visual for transparency.
|
||||
Window xembed_create_icon(Display *dpy, int screen, int size, Window tray_mgr);
|
||||
|
||||
// xembed_dock sends _NET_SYSTEM_TRAY_OPCODE SYSTEM_TRAY_REQUEST_DOCK
|
||||
// to the tray manager to embed our icon window.
|
||||
int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win);
|
||||
|
||||
// xembed_draw_icon draws ARGB pixel data onto the icon window.
|
||||
// data is in [A,R,G,B] byte order per pixel (SNI IconPixmap format).
|
||||
// img_w, img_h are the source image dimensions.
|
||||
// win_size is the target window dimension (square).
|
||||
void xembed_draw_icon(Display *dpy, Window icon_win, int win_size,
|
||||
const unsigned char *data, int img_w, int img_h);
|
||||
|
||||
// xembed_destroy_icon destroys the icon window.
|
||||
void xembed_destroy_icon(Display *dpy, Window icon_win);
|
||||
|
||||
// xembed_poll_event processes pending X11 events. Returns:
|
||||
// 0 = no actionable event
|
||||
// 1 = left button press (out_x, out_y filled)
|
||||
// 2 = right button press (out_x, out_y filled)
|
||||
// 3 = expose (needs redraw)
|
||||
// 4 = configure (resize; out_x=width, out_y=height)
|
||||
// -1 = DestroyNotify on icon window (tray died)
|
||||
int xembed_poll_event(Display *dpy, Window icon_win,
|
||||
int *out_x, int *out_y);
|
||||
|
||||
// Callback type for menu item clicks. Called with the item's dbusmenu ID.
|
||||
typedef void (*xembed_menu_click_cb)(int id);
|
||||
|
||||
// xembed_popup_menu builds and shows a GTK3 popup menu.
|
||||
// items is an array of menu item descriptors, count is the number of items.
|
||||
// cb is called (from the GTK main thread) when an item is clicked.
|
||||
// x, y are root coordinates for positioning the popup.
|
||||
// This must be called from the GTK main thread (use g_idle_add).
|
||||
|
||||
typedef struct {
|
||||
int id; // dbusmenu item ID
|
||||
const char *label; // display label (NULL for separator)
|
||||
int enabled; // whether the item is clickable
|
||||
int is_check; // whether this is a checkbox item
|
||||
int checked; // checkbox state (0 or 1)
|
||||
int is_separator;// 1 if this is a separator
|
||||
} xembed_menu_item;
|
||||
|
||||
// Schedule a GTK popup menu on the main thread.
|
||||
void xembed_show_popup_menu(xembed_menu_item *items, int count,
|
||||
xembed_menu_click_cb cb, int x, int y);
|
||||
|
||||
#endif
|
||||
71
go.mod
@@ -7,20 +7,20 @@ toolchain go1.25.5
|
||||
require (
|
||||
cunicu.li/go-rosenpass v0.4.0
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/cloudflare/circl v1.3.3 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/golang/protobuf v1.5.4
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/kardianos/service v1.2.3-0.20240613133416-becf2eb62b83
|
||||
github.com/onsi/ginkgo v1.16.5
|
||||
github.com/onsi/gomega v1.27.6
|
||||
github.com/onsi/gomega v1.34.1
|
||||
github.com/rs/cors v1.8.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
||||
@@ -51,7 +51,7 @@ require (
|
||||
github.com/eko/gocache/store/redis/v4 v4.2.2
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/godbus/dbus/v5 v5.1.0
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang/mock v1.6.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
@@ -111,13 +111,13 @@ require (
|
||||
go.uber.org/mock v0.5.2
|
||||
go.uber.org/zap v1.27.0
|
||||
goauthentik.io/api/v3 v3.2023051.3
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
|
||||
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab
|
||||
golang.org/x/mod v0.30.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/mod v0.32.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.38.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/api v0.257.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -132,16 +132,18 @@ require (
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/AppsFlyer/go-sundheit v0.6.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.3.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/awnumar/memcall v0.4.0 // indirect
|
||||
@@ -163,18 +165,21 @@ require (
|
||||
github.com/aws/smithy-go v1.22.2 // indirect
|
||||
github.com/beevik/etree v1.6.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/caddyserver/zerossl v0.1.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.0.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fredbi/uri v1.1.1 // indirect
|
||||
github.com/fyne-io/gl-js v0.2.0 // indirect
|
||||
@@ -182,6 +187,9 @@ require (
|
||||
github.com/fyne-io/image v0.1.1 // indirect
|
||||
github.com/fyne-io/oksvg v0.2.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
@@ -193,6 +201,7 @@ require (
|
||||
github.com/go-text/render v0.2.0 // indirect
|
||||
github.com/go-text/typesetting v0.2.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
@@ -207,6 +216,8 @@ require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -214,14 +225,20 @@ require (
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/kelseyhightower/envconfig v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/libdns/libdns v0.2.2 // indirect
|
||||
github.com/lmittmann/tint v1.1.2 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mdelapenya/tlscert v0.2.0 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
@@ -240,7 +257,7 @@ require (
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||
github.com/nxadm/tail v1.4.8 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.19.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.10 // indirect
|
||||
@@ -248,18 +265,24 @@ require (
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/transport/v2 v2.2.4 // indirect
|
||||
github.com/pion/turn/v4 v4.1.1 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/russellhaering/goxmldsig v1.5.0 // indirect
|
||||
github.com/rymdport/portal v0.4.2 // indirect
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.1 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
@@ -267,8 +290,11 @@ require (
|
||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
@@ -276,13 +302,14 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/image v0.33.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/image v0.35.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502
|
||||
|
||||
104
go.sum
@@ -9,6 +9,8 @@ cunicu.li/go-rosenpass v0.4.0 h1:LtPtBgFWY/9emfgC4glKLEqS0MJTylzV6+ChRhiZERw=
|
||||
cunicu.li/go-rosenpass v0.4.0/go.mod h1:MPbjH9nxV4l3vEagKVdFNwHOketqgS5/To1VYJplf/M=
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ=
|
||||
@@ -25,17 +27,25 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
|
||||
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
|
||||
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible h1:hqcTK6ZISdip65SR792lwYJTa/axESA0889D3UlZbLo=
|
||||
github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible/go.mod h1:6B1nuc1MUs6c62ODZDl7hVE5Pv7O2XGSkgg2olnq34I=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
@@ -88,6 +98,8 @@ github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
|
||||
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
@@ -122,6 +134,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:/DS5cDX3FJdl+XaN2D7XAwFpuanTxnp52DBLZAaJKx0=
|
||||
github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -139,12 +153,16 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/eko/gocache/lib/v4 v4.2.0 h1:MNykyi5Xw+5Wu3+PUrvtOCaKSZM1nUSVftbzmeC7Yuw=
|
||||
github.com/eko/gocache/lib/v4 v4.2.0/go.mod h1:7ViVmbU+CzDHzRpmB4SXKyyzyuJ8A3UW3/cszpcqB4M=
|
||||
github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEMl+NivxtR5R3/hw=
|
||||
github.com/eko/gocache/store/go_cache/v4 v4.2.2/go.mod h1:T9zkHokzr8K9EiC7RfMbDg6HSwaV6rv3UdcNu13SGcA=
|
||||
github.com/eko/gocache/store/redis/v4 v4.2.2 h1:Thw31fzGuH3WzJywsdbMivOmP550D6JS7GDHhvCJPA0=
|
||||
github.com/eko/gocache/store/redis/v4 v4.2.2/go.mod h1:LaTxLKx9TG/YUEybQvPMij++D7PBTIJ4+pzvk0ykz0w=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
@@ -173,6 +191,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||
@@ -207,10 +231,14 @@ github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -293,6 +321,10 @@ github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
@@ -323,13 +355,18 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6U
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -341,6 +378,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
@@ -348,6 +389,8 @@ github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
|
||||
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA=
|
||||
github.com/libdns/route53 v1.5.0/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q=
|
||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU=
|
||||
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
@@ -355,9 +398,14 @@ github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tA
|
||||
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -429,10 +477,12 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
|
||||
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
|
||||
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -473,6 +523,10 @@ github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
|
||||
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8=
|
||||
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
|
||||
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -497,6 +551,9 @@ github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9M
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so=
|
||||
@@ -508,6 +565,10 @@ github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvD
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU=
|
||||
github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8=
|
||||
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
|
||||
@@ -518,18 +579,24 @@ github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
|
||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
@@ -541,6 +608,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
@@ -583,14 +651,22 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0=
|
||||
@@ -641,6 +717,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
@@ -650,10 +727,16 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab h1:Iqyc+2zr7aGyLuEadIm0KRJP0Wwt+fhlXLa51Fxf1+Q=
|
||||
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab/go.mod h1:Eq3Nh/5pFSWug2ohiudJ1iyU59SO78QFuh4qTTN++I0=
|
||||
@@ -668,6 +751,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
@@ -677,6 +762,7 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
@@ -688,6 +774,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
@@ -711,14 +799,18 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -728,6 +820,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -740,6 +833,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -754,9 +849,12 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
@@ -767,6 +865,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -782,6 +882,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -832,6 +934,8 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI
|
||||
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||