Compare commits

..

1 Commits

Author SHA1 Message Date
Zoltán Papp
9ae1ca1c8e [client/uiwails] Windows cross-build support and notification actions
- Add build/windows/Taskfile.yml to cross-compile from Linux with
  mingw-w64 (CC=x86_64-w64-mingw32-gcc, CGO_ENABLED=1, -H=windowsgui).
- Rename xembed_tray.{c,h} to xembed_tray_linux.{c,h} so the Go
  toolchain only compiles these X11/GTK sources on Linux.
- Add sendShowWindowSignal on Windows: opens the named Global event
  and calls SetEvent so the already-running instance shows its window.
- Register a notification category with Open/Dismiss action buttons
  and wire a response handler. Do this inside the ApplicationStarted
  hook so it runs after the notifications service's Startup has
  initialized appName/appGUID on Windows; otherwise the category is
  saved under an empty registry path and SendNotificationWithActions
  silently falls back to a plain toast with no buttons.
- Bump github.com/wailsapp/wails/v3 to v3.0.0-alpha.78.
2026-04-22 16:53:40 +02:00
67 changed files with 129 additions and 16958 deletions

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
function BirdMark({ className }: { className?: string }) {
return (
<svg className={className} width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
</svg>
)
}
export default function NetBirdLogo({ full = false, className }: { full?: boolean; className?: string }) {
if (!full) return <BirdMark className={className} />
return (
<div className={`flex items-center gap-2 ${className ?? ''}`}>
<BirdMark />
<span className="text-lg font-bold tracking-wide" style={{ color: 'var(--color-text-primary)' }}>NETBIRD</span>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ includes:
common: ./build/Taskfile.yml
linux: ./build/linux/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
windows: ./build/windows/Taskfile.yml
vars:
APP_NAME: "netbird-ui"

View File

@@ -0,0 +1,41 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Cross-compiles the application for Windows from Linux using mingw-w64
cmds:
- task: build:cross
vars:
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
build:cross:
summary: Cross-compiles for Windows with mingw-w64
internal: true
deps:
- task: common:build:frontend
vars:
DEV:
ref: .DEV
preconditions:
- sh: command -v {{.CC}}
msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64"
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l" -ldflags="-H=windowsgui"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H=windowsgui"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
env:
GOOS: windows
GOARCH: amd64
CGO_ENABLED: 1
CC: '{{.CC}}'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'

View File

@@ -124,13 +124,48 @@ func main() {
evtManager := event.NewManager(*daemonAddr, notify)
go evtManager.Start(ctx)
// TEST: fire a desktop notification shortly after startup so we can
// verify that the notification pipeline works end-to-end.
go func() {
time.Sleep(3 * time.Second)
log.Infof("--- trigger notification ---")
notify("NetBird Test", "If you see this, notifications are working!")
}()
// Response handler can be wired early — it's just a callback registration.
const testCategoryID = "netbird-test-actions"
notifSvc.OnNotificationResponse(func(result notifications.NotificationResult) {
if result.Error != nil {
log.Warnf("notification response error: %v", result.Error)
return
}
log.Infof("notification action: id=%q category=%q", result.Response.ActionIdentifier, result.Response.CategoryID)
if result.Response.ActionIdentifier == "open" {
window.Show()
}
})
// Category registration and the test notification must happen AFTER the
// notifications service has run its Startup (which initializes appName,
// appGUID, and the COM activator on Windows). ApplicationStarted fires
// after all services are started.
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
if err := notifSvc.RegisterNotificationCategory(notifications.NotificationCategory{
ID: testCategoryID,
Actions: []notifications.NotificationAction{
{ID: "open", Title: "Open NetBird"},
{ID: "dismiss", Title: "Dismiss"},
},
}); err != nil {
log.Warnf("register notification category: %v", err)
return
}
go func() {
time.Sleep(3 * time.Second)
log.Infof("--- trigger notification ---")
if err := notifSvc.SendNotificationWithActions(notifications.NotificationOptions{
ID: "netbird-test",
Title: "NetBird Test (with buttons)",
Body: "ACTIONS TEST — you should see Open/Dismiss buttons below this text.",
CategoryID: testCategoryID,
}); err != nil {
log.Warnf("send notification with actions: %v", err)
}
}()
})
if err := app.Run(); err != nil {
log.Fatalf("app run: %v", err)

View File

@@ -49,6 +49,23 @@ func setupSignalHandler(ctx context.Context, window *application.WebviewWindow)
go waitForWindowsEvent(ctx, eventHandle, window)
}
// sendShowWindowSignal signals the already-running instance (identified by pid,
// unused on Windows since the event is named globally) to show its window.
func sendShowWindowSignal(_ int32) error {
eventNamePtr, err := windows.UTF16PtrFromString(fancyUITriggerEventName)
if err != nil {
return err
}
handle, err := windows.OpenEvent(desiredAccesses, false, eventNamePtr)
if err != nil {
return err
}
defer windows.CloseHandle(handle)
return windows.SetEvent(handle)
}
func waitForWindowsEvent(ctx context.Context, eventHandle windows.Handle, window *application.WebviewWindow) {
defer func() {
if err := windows.CloseHandle(eventHandle); err != nil {

View File

@@ -5,7 +5,7 @@ package main
/*
#cgo pkg-config: x11 gtk+-3.0
#cgo LDFLAGS: -lX11
#include "xembed_tray.h"
#include "xembed_tray_linux.h"
#include <X11/Xlib.h>
#include <stdlib.h>
*/

View File

@@ -1,4 +1,4 @@
#include "xembed_tray.h"
#include "xembed_tray_linux.h"
#include <X11/Xatom.h>
#include <X11/Xutil.h>

4
go.mod
View File

@@ -101,6 +101,7 @@ require (
github.com/ti-mo/conntrack v0.5.1
github.com/ti-mo/netfilter v0.5.2
github.com/vmihailenco/msgpack/v5 v5.4.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.78
github.com/yusufpapurcu/wmi v1.2.4
github.com/zcalusic/sysinfo v1.1.3
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0
@@ -134,6 +135,7 @@ require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // 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
@@ -257,7 +259,6 @@ 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.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
@@ -291,7 +292,6 @@ require (
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/xanzy/ssh-agent v0.3.3 // indirect
github.com/yuin/goldmark v1.7.16 // indirect

71
go.sum
View File

@@ -7,8 +7,6 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
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=
@@ -17,6 +15,8 @@ fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ=
fyne.io/fyne/v2 v2.7.0/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE=
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 h1:829+77I4TaMrcg9B3wf+gHhdSgoCVEgH2czlPXPbfj4=
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk=
@@ -25,14 +25,11 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
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 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
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=
@@ -52,6 +49,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g=
github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w=
github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A=
@@ -151,8 +150,6 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
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=
@@ -161,6 +158,8 @@ github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEM
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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
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=
@@ -195,6 +194,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
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-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
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=
@@ -203,6 +204,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -220,17 +223,16 @@ github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZs
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
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=
@@ -272,8 +274,8 @@ github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg=
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -359,12 +361,9 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW
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 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
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=
@@ -399,6 +398,8 @@ github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dt
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/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/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=
@@ -475,13 +476,11 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
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 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
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 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
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=
@@ -588,12 +587,10 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE
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 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
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=
@@ -653,8 +650,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh
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/wailsapp/wails/v3 v3.0.0-alpha.78 h1:31nJb4N8X+SIBZ88RNkptFA1eUnBOH805tDV0sN7Vpk=
github.com/wailsapp/wails/v3 v3.0.0-alpha.78/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=
@@ -663,8 +660,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
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=
@@ -725,16 +720,10 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
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=
@@ -749,8 +738,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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=
@@ -772,8 +759,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
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=
@@ -831,8 +816,6 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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=
@@ -847,8 +830,6 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
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=
@@ -863,8 +844,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=
@@ -880,8 +859,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
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=