mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-01 02:59:08 +02:00
Wails UI
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetBird</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2502
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "netbird-fancyui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wailsio/runtime": "latest",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { HashRouter, Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
import Status from './pages/Status'
|
||||
import Settings from './pages/Settings'
|
||||
import Networks from './pages/Networks'
|
||||
import Profiles from './pages/Profiles'
|
||||
import Peers from './pages/Peers'
|
||||
import Debug from './pages/Debug'
|
||||
import Update from './pages/Update'
|
||||
import NavBar from './components/NavBar'
|
||||
|
||||
/**
|
||||
* Navigator listens for the "navigate" event emitted by the Go backend
|
||||
* and programmatically navigates the React router.
|
||||
*/
|
||||
function Navigator() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = Events.On('navigate', (event: { data: string[] }) => {
|
||||
const path = event.data[0]
|
||||
if (path) navigate(path)
|
||||
})
|
||||
return () => {
|
||||
if (typeof unsub === 'function') unsub()
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Navigator />
|
||||
<div className="min-h-screen bg-nb-gray-DEFAULT text-nb-gray-100 flex">
|
||||
<NavBar />
|
||||
<main className="flex-1 p-6 overflow-y-auto h-screen">
|
||||
<Routes>
|
||||
<Route path="/" element={<Status />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/peers" element={<Peers />} />
|
||||
<Route path="/networks" element={<Networks />} />
|
||||
<Route path="/profiles" element={<Profiles />} />
|
||||
<Route path="/debug" element={<Debug />} />
|
||||
<Route path="/update" element={<Update />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</HashRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Type definitions for the auto-generated Wails v3 service bindings.
|
||||
* Run `wails3 generate bindings` to regenerate the actual TypeScript bindings
|
||||
* from the Go service methods. These types mirror the Go structs.
|
||||
*
|
||||
* The actual binding files will be generated into frontend/bindings/ by the
|
||||
* Wails CLI. This file serves as a centralized re-export and type reference.
|
||||
*/
|
||||
|
||||
// ---- Connection service ----
|
||||
|
||||
export interface StatusInfo {
|
||||
status: string
|
||||
ip: string
|
||||
publicKey: string
|
||||
fqdn: string
|
||||
connectedPeers: number
|
||||
}
|
||||
|
||||
// ---- Settings service ----
|
||||
|
||||
export interface ConfigInfo {
|
||||
managementUrl: string
|
||||
adminUrl: string
|
||||
preSharedKey: string
|
||||
interfaceName: string
|
||||
wireguardPort: number
|
||||
disableAutoConnect: boolean
|
||||
serverSshAllowed: boolean
|
||||
rosenpassEnabled: boolean
|
||||
rosenpassPermissive: boolean
|
||||
lazyConnectionEnabled: boolean
|
||||
blockInbound: boolean
|
||||
disableNotifications: boolean
|
||||
}
|
||||
|
||||
// ---- Network service ----
|
||||
|
||||
export interface NetworkInfo {
|
||||
id: string
|
||||
range: string
|
||||
domains: string[]
|
||||
selected: boolean
|
||||
resolvedIPs: Record<string, string[]>
|
||||
}
|
||||
|
||||
// ---- Profile service ----
|
||||
|
||||
export interface ProfileInfo {
|
||||
name: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface ActiveProfileInfo {
|
||||
profileName: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
// ---- Debug service ----
|
||||
|
||||
export interface DebugBundleParams {
|
||||
anonymize: boolean
|
||||
systemInfo: boolean
|
||||
upload: boolean
|
||||
uploadUrl: string
|
||||
runDurationMins: number
|
||||
enablePersistence: boolean
|
||||
}
|
||||
|
||||
export interface DebugBundleResult {
|
||||
localPath: string
|
||||
uploadedKey: string
|
||||
uploadFailureReason: string
|
||||
}
|
||||
|
||||
// ---- Peers service ----
|
||||
|
||||
export interface PeerInfo {
|
||||
ip: string
|
||||
pubKey: string
|
||||
fqdn: string
|
||||
connStatus: string
|
||||
connStatusUpdate: string
|
||||
relayed: boolean
|
||||
relayAddress: string
|
||||
latencyMs: number
|
||||
bytesRx: number
|
||||
bytesTx: number
|
||||
rosenpassEnabled: boolean
|
||||
networks: string[]
|
||||
lastHandshake: string
|
||||
localIceType: string
|
||||
remoteIceType: string
|
||||
localEndpoint: string
|
||||
remoteEndpoint: string
|
||||
}
|
||||
|
||||
// ---- Update service ----
|
||||
|
||||
export interface InstallerResult {
|
||||
success: boolean
|
||||
errorMsg: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wails v3 service call helper.
|
||||
* After running `wails3 generate bindings`, use the generated functions directly.
|
||||
* This helper wraps window.__wails.call for manual use during development.
|
||||
*/
|
||||
export async function call<T>(service: string, method: string, ...args: unknown[]): Promise<T> {
|
||||
// This will be replaced by generated bindings after `wails3 generate bindings`
|
||||
// For now, call via the Wails runtime bridge
|
||||
const w = window as typeof window & {
|
||||
go?: {
|
||||
[svc: string]: {
|
||||
[method: string]: (...args: unknown[]) => Promise<T>
|
||||
}
|
||||
}
|
||||
}
|
||||
const svc = w.go?.[service]
|
||||
if (!svc) throw new Error(`Service ${service} not found. Run wails3 generate bindings.`)
|
||||
const fn = svc[method]
|
||||
if (!fn) throw new Error(`Method ${service}.${method} not found.`)
|
||||
return fn(...args)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import NetBirdLogo from './NetBirdLogo'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Status', icon: StatusIcon },
|
||||
{ to: '/peers', label: 'Peers', icon: PeersIcon },
|
||||
{ to: '/networks', label: 'Networks', icon: NetworksIcon },
|
||||
{ to: '/profiles', label: 'Profiles', icon: ProfilesIcon },
|
||||
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ to: '/debug', label: 'Debug', icon: DebugIcon },
|
||||
{ to: '/update', label: 'Update', icon: UpdateIcon },
|
||||
]
|
||||
|
||||
export default function NavBar() {
|
||||
return (
|
||||
<nav className="w-[15rem] min-w-[15rem] bg-nb-gray-950 border-r border-nb-gray-900 flex flex-col h-screen">
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-5 border-b border-nb-gray-900">
|
||||
<NetBirdLogo full />
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-[.87rem] font-normal transition-colors ${
|
||||
isActive
|
||||
? 'bg-nb-gray-900 text-white'
|
||||
: 'text-nb-gray-400 hover:text-white hover:bg-nb-gray-900/50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon active={isActive} />
|
||||
<span>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Version footer */}
|
||||
<div className="px-5 py-3 border-t border-nb-gray-900 text-xs text-nb-gray-500">
|
||||
NetBird Client
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PeersIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworksIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<circle cx="5" cy="19" r="2" />
|
||||
<circle cx="19" cy="19" r="2" />
|
||||
<line x1="12" y1="7" x2="5" y2="17" />
|
||||
<line x1="12" y1="7" x2="19" y2="17" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfilesIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DebugIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
|
||||
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
|
||||
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
function BirdMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NetBirdLogo({ full = false, className }: { full?: boolean; className?: string }) {
|
||||
if (!full) return <BirdMark className={className} />
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className ?? ''}`}>
|
||||
<BirdMark />
|
||||
<span className="text-lg font-bold tracking-wide text-nb-gray-100">NETBIRD</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-netbird-50: #fff6ed;
|
||||
--color-netbird-100: #feecd6;
|
||||
--color-netbird-150: #ffdfb8;
|
||||
--color-netbird-200: #ffd4a6;
|
||||
--color-netbird-300: #fab677;
|
||||
--color-netbird-400: #f68330;
|
||||
--color-netbird-DEFAULT: #f68330;
|
||||
--color-netbird-500: #f46d1b;
|
||||
--color-netbird-600: #e55311;
|
||||
--color-netbird-700: #be3e10;
|
||||
--color-netbird-800: #973215;
|
||||
--color-netbird-900: #7a2b14;
|
||||
--color-netbird-950: #421308;
|
||||
|
||||
--color-nb-gray-DEFAULT: #181a1d;
|
||||
--color-nb-gray-50: #f4f6f7;
|
||||
--color-nb-gray-100: #e4e7e9;
|
||||
--color-nb-gray-200: #cbd2d6;
|
||||
--color-nb-gray-300: #a3adb5;
|
||||
--color-nb-gray-400: #7c8994;
|
||||
--color-nb-gray-500: #616e79;
|
||||
--color-nb-gray-600: #535d67;
|
||||
--color-nb-gray-700: #474e57;
|
||||
--color-nb-gray-800: #3f444b;
|
||||
--color-nb-gray-900: #2e3238;
|
||||
--color-nb-gray-910: #292c31;
|
||||
--color-nb-gray-920: #25282d;
|
||||
--color-nb-gray-930: #212428;
|
||||
--color-nb-gray-940: #1c1e21;
|
||||
--color-nb-gray-950: #181a1d;
|
||||
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background-color: #181a1d;
|
||||
color: #e4e7e9;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Toggle switch — matches dashboard ToggleSwitch.tsx */
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
background-color: var(--color-nb-gray-700);
|
||||
}
|
||||
.toggle-track[aria-checked="true"] {
|
||||
background-color: var(--color-netbird-400);
|
||||
}
|
||||
.toggle-thumb {
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background-color: white;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transition: transform 0.2s;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toggle-track[aria-checked="true"] .toggle-thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Small toggle (matches dashboard ToggleSwitch size=small) */
|
||||
.toggle-track-sm {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
background-color: var(--color-nb-gray-700);
|
||||
}
|
||||
.toggle-track-sm[aria-checked="true"] {
|
||||
background-color: var(--color-netbird-400);
|
||||
}
|
||||
.toggle-thumb-sm {
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
background-color: white;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transition: transform 0.2s;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toggle-track-sm[aria-checked="true"] .toggle-thumb-sm {
|
||||
transform: translateX(17px);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { DebugBundleParams, DebugBundleResult } from '../bindings'
|
||||
|
||||
const DEFAULT_UPLOAD_URL = 'https://upload.netbird.io'
|
||||
|
||||
export default function Debug() {
|
||||
const [anonymize, setAnonymize] = useState(false)
|
||||
const [systemInfo, setSystemInfo] = useState(true)
|
||||
const [upload, setUpload] = useState(true)
|
||||
const [uploadUrl, setUploadUrl] = useState(DEFAULT_UPLOAD_URL)
|
||||
const [runForDuration, setRunForDuration] = useState(true)
|
||||
const [durationMins, setDurationMins] = useState(1)
|
||||
|
||||
const [running, setRunning] = useState(false)
|
||||
const [progress, setProgress] = useState('')
|
||||
const [result, setResult] = useState<DebugBundleResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleCreate() {
|
||||
if (upload && !uploadUrl) {
|
||||
setError('Upload URL is required when upload is enabled')
|
||||
return
|
||||
}
|
||||
|
||||
setRunning(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setProgress(runForDuration ? `Running with trace logs for ${durationMins} minute(s)…` : 'Creating debug bundle…')
|
||||
|
||||
const params: DebugBundleParams = {
|
||||
anonymize,
|
||||
systemInfo,
|
||||
upload,
|
||||
uploadUrl: upload ? uploadUrl : '',
|
||||
runDurationMins: runForDuration ? durationMins : 0,
|
||||
enablePersistence: true,
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Debug] calling services.DebugService.CreateDebugBundle')
|
||||
const res = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.DebugService.CreateDebugBundle', params) as DebugBundleResult
|
||||
console.log('[Debug] CreateDebugBundle result:', JSON.stringify(res))
|
||||
if (res) {
|
||||
setResult(res)
|
||||
setProgress('Bundle created successfully')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Debug] CreateDebugBundle error:', e)
|
||||
setError(String(e))
|
||||
setProgress('')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-2">Debug</h1>
|
||||
<p className="text-nb-gray-400 text-sm mb-6">
|
||||
Create a debug bundle to help troubleshoot issues with NetBird.
|
||||
</p>
|
||||
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 space-y-5">
|
||||
<Toggle label="Anonymize sensitive information (IPs, domains…)" checked={anonymize} onChange={setAnonymize} />
|
||||
<Toggle label="Include system information (routes, interfaces…)" checked={systemInfo} onChange={setSystemInfo} />
|
||||
<Toggle label="Upload bundle automatically after creation" checked={upload} onChange={setUpload} />
|
||||
|
||||
{upload && (
|
||||
<div>
|
||||
<label className="block text-sm text-nb-gray-400 mb-1.5">Upload URL</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 bg-nb-gray-900 border border-nb-gray-800 rounded-lg text-sm focus:outline-none focus:border-netbird"
|
||||
value={uploadUrl}
|
||||
onChange={e => setUploadUrl(e.target.value)}
|
||||
disabled={running}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-nb-gray-900 pt-4">
|
||||
<Toggle
|
||||
label="Run with trace logs before creating bundle"
|
||||
checked={runForDuration}
|
||||
onChange={setRunForDuration}
|
||||
/>
|
||||
{runForDuration && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-nb-gray-400">for</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={durationMins}
|
||||
onChange={e => setDurationMins(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
disabled={running}
|
||||
className="w-16 px-2 py-1 bg-nb-gray-900 border border-nb-gray-800 rounded text-sm text-center focus:outline-none focus:border-netbird"
|
||||
/>
|
||||
<span className="text-sm text-nb-gray-400">{durationMins === 1 ? 'minute' : 'minutes'}</span>
|
||||
</div>
|
||||
)}
|
||||
{runForDuration && (
|
||||
<p className="text-xs text-nb-gray-500 mt-2">
|
||||
Note: NetBird will be brought up and down during collection.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div className="mt-4 p-3 bg-nb-gray-920 border border-nb-gray-900 rounded-lg text-sm">
|
||||
<span className={running ? 'animate-pulse text-yellow-300' : 'text-green-400'}>{progress}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="mt-4 bg-nb-gray-920 rounded-xl p-5 border border-nb-gray-900 text-sm space-y-2">
|
||||
{result.uploadedKey ? (
|
||||
<>
|
||||
<p className="text-green-400 font-medium">Bundle uploaded successfully!</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-nb-gray-400">Upload key:</span>
|
||||
<code className="bg-nb-gray-900 px-2 py-0.5 rounded text-xs font-mono">{result.uploadedKey}</code>
|
||||
</div>
|
||||
</>
|
||||
) : result.uploadFailureReason ? (
|
||||
<p className="text-yellow-400">Upload failed: {result.uploadFailureReason}</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-nb-gray-400">Local path:</span>
|
||||
<code className="bg-nb-gray-900 px-2 py-0.5 rounded text-xs font-mono break-all">{result.localPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={running}
|
||||
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{running ? 'Running…' : 'Create Debug Bundle'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className="toggle-track"
|
||||
>
|
||||
<span className="toggle-thumb" />
|
||||
</button>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { NetworkInfo } from '../bindings'
|
||||
|
||||
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.NetworkService'
|
||||
|
||||
type Tab = 'all' | 'overlapping' | 'exit-node'
|
||||
type SortKey = 'id' | 'range'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
export default function Networks() {
|
||||
const [networks, setNetworks] = useState<NetworkInfo[]>([])
|
||||
const [tab, setTab] = useState<Tab>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState<SortKey>('id')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
let method: string
|
||||
if (tab === 'all') method = 'ListNetworks'
|
||||
else if (tab === 'overlapping') method = 'ListOverlappingNetworks'
|
||||
else method = 'ListExitNodes'
|
||||
const data = await Call.ByName(`${SVC}.${method}`) as NetworkInfo[]
|
||||
setNetworks(data ?? [])
|
||||
} catch (e) {
|
||||
console.error('[Networks] load error:', e)
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [tab])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const id = setInterval(load, 10000)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = networks
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
list = list.filter(n =>
|
||||
n.id.toLowerCase().includes(q) ||
|
||||
n.range?.toLowerCase().includes(q) ||
|
||||
n.domains?.some(d => d.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
return [...list].sort((a, b) => {
|
||||
const aVal = sortKey === 'id' ? a.id : (a.range ?? '')
|
||||
const bVal = sortKey === 'id' ? b.id : (b.range ?? '')
|
||||
const cmp = aVal.localeCompare(bVal)
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [networks, search, sortKey, sortDir])
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(id: string, selected: boolean) {
|
||||
try {
|
||||
if (selected) await Call.ByName(`${SVC}.DeselectNetwork`, id)
|
||||
else await Call.ByName(`${SVC}.SelectNetwork`, id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function selectAll() {
|
||||
try {
|
||||
await Call.ByName(`${SVC}.SelectAllNetworks`)
|
||||
await load()
|
||||
} catch (e) { setError(String(e)) }
|
||||
}
|
||||
|
||||
async function deselectAll() {
|
||||
try {
|
||||
await Call.ByName(`${SVC}.DeselectAllNetworks`)
|
||||
await load()
|
||||
} catch (e) { setError(String(e)) }
|
||||
}
|
||||
|
||||
const selectedCount = networks.filter(n => n.selected).length
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">Networks</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-5 bg-nb-gray-920 p-1 rounded-lg border border-nb-gray-900 max-w-md">
|
||||
{([['all', 'All Networks'], ['overlapping', 'Overlapping'], ['exit-node', 'Exit Nodes']] as [Tab, string][]).map(([t, label]) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
tab === t ? 'bg-netbird text-white' : 'text-nb-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar: search + actions */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-nb-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx={11} cy={11} r={8} /><path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
className="w-full pl-9 pr-3 py-2 bg-nb-gray-920 border border-nb-gray-800/40 rounded-md text-sm text-nb-gray-100 placeholder-nb-gray-500 focus:outline-none focus:border-nb-gray-600 transition-colors"
|
||||
placeholder="Search by name, range or domain..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<ActionButton onClick={selectAll}>Select All</ActionButton>
|
||||
<ActionButton onClick={deselectAll}>Deselect All</ActionButton>
|
||||
<ActionButton onClick={load}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-950/40 border border-red-500 rounded-md text-red-400 text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection summary */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="mb-3 text-xs text-nb-gray-400">
|
||||
{selectedCount} of {networks.length} network{networks.length !== 1 ? 's' : ''} selected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{loading && networks.length === 0 ? (
|
||||
<TableSkeleton />
|
||||
) : filtered.length === 0 && networks.length === 0 ? (
|
||||
<EmptyState tab={tab} />
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-12 text-center text-nb-gray-400 text-sm">
|
||||
No networks match your search.
|
||||
<button onClick={() => setSearch('')} className="ml-2 text-netbird hover:underline">Clear search</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-nb-gray-900 border-b border-nb-gray-900/60">
|
||||
<SortableHeader label="Network" sortKey="id" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortableHeader label="Range / Domains" sortKey="range" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Resolved IPs</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide w-20">Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(n => (
|
||||
<NetworkRow key={n.id} network={n} onToggle={() => toggle(n.id, n.selected)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination footer */}
|
||||
<div className="px-4 py-2.5 bg-nb-gray-940 border-t border-nb-gray-900/60 text-xs text-nb-gray-400">
|
||||
Showing {filtered.length} of {networks.length} network{networks.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Row ---- */
|
||||
|
||||
function NetworkRow({ network, onToggle }: { network: NetworkInfo; onToggle: () => void }) {
|
||||
const domains = network.domains ?? []
|
||||
const resolvedEntries = Object.entries(network.resolvedIPs ?? {})
|
||||
const hasDomains = domains.length > 0
|
||||
|
||||
return (
|
||||
<tr className="border-b border-nb-gray-900/40 hover:bg-nb-gray-940 transition-colors group/row">
|
||||
{/* Network name cell (dashboard-style icon square + name) */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<div className="flex items-center gap-3 min-w-[180px]">
|
||||
<NetworkSquare name={network.id} active={network.selected} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm text-nb-gray-100">{network.id}</span>
|
||||
{hasDomains && domains.length > 1 && (
|
||||
<span className="text-xs text-nb-gray-500 mt-0.5">{domains.length} domains</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Range / Domains */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
{hasDomains ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{domains.slice(0, 2).map(d => (
|
||||
<span key={d} className="font-mono text-[0.82rem] text-nb-gray-300">{d}</span>
|
||||
))}
|
||||
{domains.length > 2 && (
|
||||
<span className="text-xs text-nb-gray-500" title={domains.join(', ')}>+{domains.length - 2} more</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-mono text-[0.82rem] text-nb-gray-300">{network.range}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Resolved IPs */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
{resolvedEntries.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{resolvedEntries.slice(0, 2).map(([domain, ips]) => (
|
||||
<span key={domain} className="font-mono text-xs text-nb-gray-400" title={`${domain}: ${ips.join(', ')}`}>
|
||||
{ips[0]}{ips.length > 1 && <span className="text-nb-gray-600"> +{ips.length - 1}</span>}
|
||||
</span>
|
||||
))}
|
||||
{resolvedEntries.length > 2 && (
|
||||
<span className="text-xs text-nb-gray-600">+{resolvedEntries.length - 2} more</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-nb-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Active toggle */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={network.selected}
|
||||
onClick={onToggle}
|
||||
className="toggle-track-sm"
|
||||
>
|
||||
<span className="toggle-thumb-sm" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Network Icon Square (matches dashboard NetworkInformationSquare) ---- */
|
||||
|
||||
function NetworkSquare({ name, active }: { name: string; active: boolean }) {
|
||||
const initials = name.substring(0, 2).toUpperCase()
|
||||
return (
|
||||
<div className="relative h-10 w-10 shrink-0 rounded-md bg-nb-gray-800 flex items-center justify-center text-sm font-medium text-nb-gray-100 uppercase">
|
||||
{initials}
|
||||
{/* Status dot */}
|
||||
<div className={`absolute bottom-0 right-0 h-2 w-2 rounded-full z-10 ${active ? 'bg-green-500' : 'bg-nb-gray-700'}`} />
|
||||
{/* Corner mask for rounded dot cutout */}
|
||||
<div className="absolute bottom-0 right-0 h-3 w-3 bg-nb-gray-950 rounded-tl-[8px] rounded-br group-hover/row:bg-nb-gray-940 transition-colors" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Sortable Header ---- */
|
||||
|
||||
function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
|
||||
label: string; sortKey: SortKey; currentKey: SortKey; dir: SortDir; onSort: (k: SortKey) => void
|
||||
}) {
|
||||
const isActive = currentKey === sortKey
|
||||
return (
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide cursor-pointer select-none hover:text-nb-gray-300 transition-colors"
|
||||
onClick={() => onSort(sortKey)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
{isActive && (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{dir === 'asc' ? <path d="M5 15l7-7 7 7" /> : <path d="M19 9l-7 7-7-7" />}
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Action Button ---- */
|
||||
|
||||
function ActionButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-nb-gray-920 border border-nb-gray-800/40 text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Empty State ---- */
|
||||
|
||||
function EmptyState({ tab }: { tab: Tab }) {
|
||||
const msg = tab === 'exit-node'
|
||||
? 'No exit nodes configured.'
|
||||
: tab === 'overlapping'
|
||||
? 'No overlapping networks detected.'
|
||||
: 'No networks found.'
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-nb-gray-900/60 bg-nb-gray-920 py-16 flex flex-col items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-lg bg-nb-gray-800 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-nb-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A8.966 8.966 0 013 12c0-1.777.514-3.434 1.4-4.832" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-nb-gray-400">{msg}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Loading Skeleton ---- */
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
|
||||
<div className="bg-nb-gray-900 h-11" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 px-4 py-4 border-b border-nb-gray-900/40 animate-pulse">
|
||||
<div className="w-4 h-4 rounded bg-nb-gray-800" />
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-md bg-nb-gray-800" />
|
||||
<div className="h-4 w-24 rounded bg-nb-gray-800" />
|
||||
</div>
|
||||
<div className="h-4 w-32 rounded bg-nb-gray-800" />
|
||||
<div className="h-4 w-20 rounded bg-nb-gray-800" />
|
||||
<div className="h-6 w-16 rounded-md bg-nb-gray-800" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { PeerInfo } from '../bindings'
|
||||
|
||||
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.PeersService'
|
||||
|
||||
type SortKey = 'fqdn' | 'ip' | 'status' | 'latency'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatLatency(ms: number): string {
|
||||
if (ms <= 0) return '—'
|
||||
if (ms < 1) return '<1 ms'
|
||||
return `${ms.toFixed(1)} ms`
|
||||
}
|
||||
|
||||
function peerName(p: PeerInfo): string {
|
||||
if (p.fqdn) return p.fqdn.replace(/\.netbird\.cloud\.?$/, '')
|
||||
return p.ip || p.pubKey.substring(0, 8)
|
||||
}
|
||||
|
||||
export default function Peers() {
|
||||
const [peers, setPeers] = useState<PeerInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState<SortKey>('fqdn')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await Call.ByName(`${SVC}.GetPeers`) as PeerInfo[]
|
||||
setPeers(data ?? [])
|
||||
} catch (e) {
|
||||
console.error('[Peers] load error:', e)
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const id = setInterval(load, 10000)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
const connectedCount = useMemo(() => peers.filter(p => p.connStatus === 'Connected').length, [peers])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = peers
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
list = list.filter(p =>
|
||||
peerName(p).toLowerCase().includes(q) ||
|
||||
p.ip?.toLowerCase().includes(q) ||
|
||||
p.connStatus?.toLowerCase().includes(q) ||
|
||||
p.fqdn?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return [...list].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortKey) {
|
||||
case 'fqdn': cmp = peerName(a).localeCompare(peerName(b)); break
|
||||
case 'ip': cmp = (a.ip ?? '').localeCompare(b.ip ?? ''); break
|
||||
case 'status': cmp = (a.connStatus ?? '').localeCompare(b.connStatus ?? ''); break
|
||||
case 'latency': cmp = (a.latencyMs ?? 0) - (b.latencyMs ?? 0); break
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [peers, search, sortKey, sortDir])
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">Peers</h1>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-nb-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx={11} cy={11} r={8} /><path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
className="w-full pl-9 pr-3 py-2 bg-nb-gray-920 border border-nb-gray-800/40 rounded-md text-sm text-nb-gray-100 placeholder-nb-gray-500 focus:outline-none focus:border-nb-gray-600 transition-colors"
|
||||
placeholder="Search by name, IP or status..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<ActionButton onClick={load}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-950/40 border border-red-500 rounded-md text-red-400 text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{peers.length > 0 && (
|
||||
<div className="mb-3 text-xs text-nb-gray-400">
|
||||
{connectedCount} of {peers.length} peer{peers.length !== 1 ? 's' : ''} connected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{loading && peers.length === 0 ? (
|
||||
<TableSkeleton />
|
||||
) : peers.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-12 text-center text-nb-gray-400 text-sm">
|
||||
No peers match your search.
|
||||
<button onClick={() => setSearch('')} className="ml-2 text-netbird hover:underline">Clear search</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-nb-gray-900 border-b border-nb-gray-900/60">
|
||||
<SortableHeader label="Peer" sortKey="fqdn" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortableHeader label="IP" sortKey="ip" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortableHeader label="Status" sortKey="status" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Connection</th>
|
||||
<SortableHeader label="Latency" sortKey="latency" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Transfer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(p => (
|
||||
<PeerRow key={p.pubKey} peer={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="px-4 py-2.5 bg-nb-gray-940 border-t border-nb-gray-900/60 text-xs text-nb-gray-400">
|
||||
Showing {filtered.length} of {peers.length} peer{peers.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Row ---- */
|
||||
|
||||
function PeerRow({ peer }: { peer: PeerInfo }) {
|
||||
const name = peerName(peer)
|
||||
const connected = peer.connStatus === 'Connected'
|
||||
|
||||
return (
|
||||
<tr className="border-b border-nb-gray-900/40 hover:bg-nb-gray-940 transition-colors group/row">
|
||||
{/* Peer name */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<div className="flex items-center gap-3 min-w-[160px]">
|
||||
<PeerSquare name={name} connected={connected} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm text-nb-gray-100 truncate max-w-[200px]" title={peer.fqdn}>{name}</span>
|
||||
{peer.networks && peer.networks.length > 0 && (
|
||||
<span className="text-xs text-nb-gray-500 mt-0.5">{peer.networks.length} network{peer.networks.length !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* IP */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<span className="font-mono text-[0.82rem] text-nb-gray-300">{peer.ip || '—'}</span>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<StatusBadge status={peer.connStatus} />
|
||||
</td>
|
||||
|
||||
{/* Connection type */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{connected ? (
|
||||
<>
|
||||
<span className="text-xs text-nb-gray-300">
|
||||
{peer.relayed ? 'Relayed' : 'Direct'}{' '}
|
||||
{peer.rosenpassEnabled && (
|
||||
<span className="text-green-400" title="Rosenpass post-quantum security enabled">PQ</span>
|
||||
)}
|
||||
</span>
|
||||
{peer.relayed && peer.relayAddress && (
|
||||
<span className="text-xs text-nb-gray-500 font-mono" title={peer.relayAddress}>
|
||||
via {peer.relayAddress.length > 24 ? peer.relayAddress.substring(0, 24) + '...' : peer.relayAddress}
|
||||
</span>
|
||||
)}
|
||||
{!peer.relayed && peer.localIceType && (
|
||||
<span className="text-xs text-nb-gray-500">{peer.localIceType} / {peer.remoteIceType}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-nb-gray-600">—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Latency */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<span className={`text-sm ${peer.latencyMs > 0 ? 'text-nb-gray-300' : 'text-nb-gray-600'}`}>
|
||||
{formatLatency(peer.latencyMs)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Transfer */}
|
||||
<td className="px-4 py-3 align-middle">
|
||||
{(peer.bytesRx > 0 || peer.bytesTx > 0) ? (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
<span className="text-nb-gray-400">
|
||||
<span className="text-green-400/70" title="Received">↓</span> {formatBytes(peer.bytesRx)}
|
||||
</span>
|
||||
<span className="text-nb-gray-400">
|
||||
<span className="text-blue-400/70" title="Sent">↑</span> {formatBytes(peer.bytesTx)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-nb-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Peer Icon Square ---- */
|
||||
|
||||
function PeerSquare({ name, connected }: { name: string; connected: boolean }) {
|
||||
const initials = name.substring(0, 2).toUpperCase()
|
||||
return (
|
||||
<div className="relative h-10 w-10 shrink-0 rounded-md bg-nb-gray-800 flex items-center justify-center text-sm font-medium text-nb-gray-100 uppercase">
|
||||
{initials}
|
||||
<div className={`absolute bottom-0 right-0 h-2 w-2 rounded-full z-10 ${connected ? 'bg-green-500' : 'bg-nb-gray-700'}`} />
|
||||
<div className="absolute bottom-0 right-0 h-3 w-3 bg-nb-gray-950 rounded-tl-[8px] rounded-br group-hover/row:bg-nb-gray-940 transition-colors" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Status Badge ---- */
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const connected = status === 'Connected'
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
connected
|
||||
? 'bg-green-500/10 text-green-400 border border-green-500/20'
|
||||
: 'bg-nb-gray-800/50 text-nb-gray-400 border border-nb-gray-800'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-nb-gray-600'}`} />
|
||||
{status || 'Unknown'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Sortable Header ---- */
|
||||
|
||||
function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
|
||||
label: string; sortKey: SortKey; currentKey: SortKey; dir: SortDir; onSort: (k: SortKey) => void
|
||||
}) {
|
||||
const isActive = currentKey === sortKey
|
||||
return (
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide cursor-pointer select-none hover:text-nb-gray-300 transition-colors"
|
||||
onClick={() => onSort(sortKey)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
{isActive && (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{dir === 'asc' ? <path d="M5 15l7-7 7 7" /> : <path d="M19 9l-7 7-7-7" />}
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Action Button ---- */
|
||||
|
||||
function ActionButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-nb-gray-920 border border-nb-gray-800/40 text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Empty State ---- */
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="rounded-md border border-nb-gray-900/60 bg-nb-gray-920 py-16 flex flex-col items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-lg bg-nb-gray-800 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-nb-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-nb-gray-400">No peers found. Connect to a network to see peers.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Loading Skeleton ---- */
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
|
||||
<div className="bg-nb-gray-900 h-11" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 px-4 py-4 border-b border-nb-gray-900/40 animate-pulse">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-md bg-nb-gray-800" />
|
||||
<div className="h-4 w-28 rounded bg-nb-gray-800" />
|
||||
</div>
|
||||
<div className="h-4 w-24 rounded bg-nb-gray-800" />
|
||||
<div className="h-5 w-20 rounded-full bg-nb-gray-800" />
|
||||
<div className="h-4 w-16 rounded bg-nb-gray-800" />
|
||||
<div className="h-4 w-14 rounded bg-nb-gray-800" />
|
||||
<div className="h-4 w-16 rounded bg-nb-gray-800" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { ProfileInfo } from '../bindings'
|
||||
|
||||
export default function Profiles() {
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([])
|
||||
const [newName, setNewName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
const [confirm, setConfirm] = useState<{ action: string; profile: string } | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
console.log('[Profiles] calling services.ProfileService.ListProfiles')
|
||||
const data = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.ListProfiles') as ProfileInfo[]
|
||||
console.log('[Profiles] ListProfiles returned', data?.length ?? 0, 'profiles')
|
||||
setProfiles(data ?? [])
|
||||
} catch (e) {
|
||||
console.error('[Profiles] ListProfiles error:', e)
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
function showInfo(msg: string) {
|
||||
setInfo(msg)
|
||||
setTimeout(() => setInfo(null), 3000)
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!confirm) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (confirm.action === 'switch') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.SwitchProfile', confirm.profile)
|
||||
else if (confirm.action === 'remove') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.RemoveProfile', confirm.profile)
|
||||
else if (confirm.action === 'logout') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.Logout', confirm.profile)
|
||||
showInfo(`${confirm.action === 'switch' ? 'Switched to' : confirm.action === 'remove' ? 'Removed' : 'Deregistered from'} profile '${confirm.profile}'`)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setConfirm(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!newName.trim()) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.AddProfile', newName.trim())
|
||||
showInfo(`Profile '${newName.trim()}' created`)
|
||||
setNewName('')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">Profiles</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && (
|
||||
<div className="mb-4 p-3 bg-green-900/50 border border-green-700 rounded-lg text-green-300 text-sm">
|
||||
{info}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 max-w-sm w-full mx-4 border border-nb-gray-900">
|
||||
<h2 className="text-lg font-semibold mb-2 capitalize">
|
||||
{confirm.action === 'switch' ? 'Switch Profile' : confirm.action === 'remove' ? 'Remove Profile' : 'Deregister Profile'}
|
||||
</h2>
|
||||
<p className="text-nb-gray-300 text-sm mb-5">
|
||||
{confirm.action === 'switch' && `Switch to profile '${confirm.profile}'?`}
|
||||
{confirm.action === 'remove' && `Delete profile '${confirm.profile}'? This cannot be undone.`}
|
||||
{confirm.action === 'logout' && `Deregister from '${confirm.profile}'?`}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setConfirm(null)} className="px-4 py-2 text-sm bg-nb-gray-900 hover:bg-nb-gray-800 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleConfirm} disabled={loading} className="px-4 py-2 text-sm bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile list */}
|
||||
<div className="bg-nb-gray-920 rounded-xl border border-nb-gray-900 overflow-hidden mb-6">
|
||||
{profiles.length === 0 ? (
|
||||
<div className="p-4 text-nb-gray-400 text-sm">No profiles found.</div>
|
||||
) : (
|
||||
profiles.map(p => (
|
||||
<div key={p.name} className="flex items-center gap-3 px-4 py-3 border-b border-nb-gray-900 last:border-0">
|
||||
<span className="text-green-400 w-5 text-center">
|
||||
{p.isActive ? '✓' : ''}
|
||||
</span>
|
||||
<span className="flex-1 font-medium">{p.name}</span>
|
||||
{p.isActive && <span className="text-xs text-netbird px-2 py-0.5 bg-netbird-950/40 rounded-full">Active</span>}
|
||||
<div className="flex gap-2">
|
||||
{!p.isActive && (
|
||||
<button
|
||||
onClick={() => setConfirm({ action: 'switch', profile: p.name })}
|
||||
className="px-3 py-1 text-xs bg-netbird-600 hover:bg-netbird-500 rounded transition-colors"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirm({ action: 'logout', profile: p.name })}
|
||||
className="px-3 py-1 text-xs bg-nb-gray-900 hover:bg-nb-gray-800 rounded transition-colors"
|
||||
>
|
||||
Deregister
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirm({ action: 'remove', profile: p.name })}
|
||||
className="px-3 py-1 text-xs bg-red-900 hover:bg-red-800 rounded transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add new profile */}
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="flex-1 px-3 py-2 bg-nb-gray-920 border border-nb-gray-800 rounded-lg text-sm focus:outline-none focus:border-netbird"
|
||||
placeholder="New profile name"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!newName.trim() || loading}
|
||||
className="px-4 py-2 text-sm bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
Add Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { ConfigInfo } from '../bindings'
|
||||
|
||||
async function getConfig(): Promise<ConfigInfo | null> {
|
||||
try {
|
||||
console.log('[Settings] calling services.SettingsService.GetConfig')
|
||||
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.SettingsService.GetConfig')
|
||||
console.log('[Settings] GetConfig result:', JSON.stringify(result))
|
||||
return result as ConfigInfo
|
||||
} catch (e) {
|
||||
console.error('[Settings] GetConfig error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function setConfig(cfg: ConfigInfo): Promise<void> {
|
||||
console.log('[Settings] calling services.SettingsService.SetConfig')
|
||||
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.SettingsService.SetConfig', cfg)
|
||||
}
|
||||
|
||||
type Tab = 'connection' | 'network' | 'security'
|
||||
|
||||
export default function Settings() {
|
||||
const [config, setConfigState] = useState<ConfigInfo | null>(null)
|
||||
const [tab, setTab] = useState<Tab>('connection')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getConfig().then(c => { if (c) setConfigState(c) })
|
||||
}, [])
|
||||
|
||||
function update<K extends keyof ConfigInfo>(key: K, value: ConfigInfo[K]) {
|
||||
setConfigState(prev => prev ? { ...prev, [key]: value } : prev)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!config) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
try {
|
||||
await setConfig(config)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <div className="text-nb-gray-400">Loading settings…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 bg-nb-gray-920 p-1 rounded-lg border border-nb-gray-900">
|
||||
{(['connection', 'network', 'security'] as Tab[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-2 rounded text-sm font-medium capitalize transition-colors ${
|
||||
tab === t ? 'bg-netbird text-white' : 'text-nb-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 space-y-5">
|
||||
|
||||
{tab === 'connection' && (
|
||||
<>
|
||||
<Field label="Management URL">
|
||||
<input
|
||||
className="input"
|
||||
value={config.managementUrl}
|
||||
onChange={e => update('managementUrl', e.target.value)}
|
||||
placeholder="https://api.netbird.io:443"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Admin URL">
|
||||
<input
|
||||
className="input"
|
||||
value={config.adminUrl}
|
||||
onChange={e => update('adminUrl', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Pre-shared Key">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={config.preSharedKey}
|
||||
onChange={e => update('preSharedKey', e.target.value)}
|
||||
placeholder="Leave empty to clear"
|
||||
/>
|
||||
</Field>
|
||||
<Toggle
|
||||
label="Connect automatically when service starts"
|
||||
checked={!config.disableAutoConnect}
|
||||
onChange={v => update('disableAutoConnect', !v)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Enable notifications"
|
||||
checked={!config.disableNotifications}
|
||||
onChange={v => update('disableNotifications', !v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'network' && (
|
||||
<>
|
||||
<Field label="Interface Name">
|
||||
<input
|
||||
className="input"
|
||||
value={config.interfaceName}
|
||||
onChange={e => update('interfaceName', e.target.value)}
|
||||
placeholder="netbird0"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="WireGuard Port">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={config.wireguardPort}
|
||||
onChange={e => update('wireguardPort', parseInt(e.target.value) || 0)}
|
||||
placeholder="51820"
|
||||
/>
|
||||
</Field>
|
||||
<Toggle
|
||||
label="Enable lazy connections (experimental)"
|
||||
checked={config.lazyConnectionEnabled}
|
||||
onChange={v => update('lazyConnectionEnabled', v)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Block inbound connections"
|
||||
checked={config.blockInbound}
|
||||
onChange={v => update('blockInbound', v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'security' && (
|
||||
<>
|
||||
<Toggle
|
||||
label="Allow SSH connections"
|
||||
checked={config.serverSshAllowed}
|
||||
onChange={v => update('serverSshAllowed', v)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Enable post-quantum security via Rosenpass"
|
||||
checked={config.rosenpassEnabled}
|
||||
onChange={v => update('rosenpassEnabled', v)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Rosenpass permissive mode"
|
||||
checked={config.rosenpassPermissive}
|
||||
onChange={v => update('rosenpassPermissive', v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{saved && <span className="text-green-400 text-sm">Saved!</span>}
|
||||
{error && <span className="text-red-400 text-sm">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-nb-gray-400 mb-1.5">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className="toggle-track"
|
||||
>
|
||||
<span className="toggle-thumb" />
|
||||
</button>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Events, Call } from '@wailsio/runtime'
|
||||
import type { StatusInfo } from '../bindings'
|
||||
|
||||
async function getStatus(): Promise<StatusInfo | null> {
|
||||
try {
|
||||
console.log('[Dashboard] calling services.ConnectionService.GetStatus')
|
||||
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.GetStatus')
|
||||
console.log('[Dashboard] GetStatus result:', JSON.stringify(result))
|
||||
return result as StatusInfo
|
||||
} catch (e) {
|
||||
console.error('[Dashboard] GetStatus error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
console.log('[Dashboard] calling services.ConnectionService.Connect')
|
||||
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Connect')
|
||||
}
|
||||
|
||||
async function disconnect(): Promise<void> {
|
||||
console.log('[Dashboard] calling services.ConnectionService.Disconnect')
|
||||
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Disconnect')
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'Connected': return 'text-green-400'
|
||||
case 'Connecting': return 'text-yellow-400'
|
||||
case 'Disconnected': return 'text-nb-gray-400'
|
||||
default: return 'text-red-400'
|
||||
}
|
||||
}
|
||||
|
||||
function statusDot(status: string): string {
|
||||
switch (status) {
|
||||
case 'Connected': return 'bg-green-400'
|
||||
case 'Connecting': return 'bg-yellow-400 animate-pulse'
|
||||
case 'Disconnected': return 'bg-nb-gray-600'
|
||||
default: return 'bg-red-400'
|
||||
}
|
||||
}
|
||||
|
||||
export default function Status() {
|
||||
const [status, setStatus] = useState<StatusInfo | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const s = await getStatus()
|
||||
if (s) setStatus(s)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
// Poll every 10 seconds as fallback (push events handle real-time updates)
|
||||
const id = setInterval(refresh, 10000)
|
||||
// Also listen for push events from the tray
|
||||
const unsub = Events.On('status-changed', (event: { data: StatusInfo[] }) => {
|
||||
if (event.data[0]) setStatus(event.data[0])
|
||||
})
|
||||
return () => {
|
||||
clearInterval(id)
|
||||
if (typeof unsub === 'function') unsub()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
async function handleConnect() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await connect()
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await disconnect()
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isConnected = status?.status === 'Connected'
|
||||
const isConnecting = status?.status === 'Connecting'
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">Status</h1>
|
||||
|
||||
{/* Status card */}
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 mb-6 border border-nb-gray-900">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className={`w-3 h-3 rounded-full ${status ? statusDot(status.status) : 'bg-nb-gray-600'}`} />
|
||||
<span className={`text-xl font-semibold ${status ? statusColor(status.status) : 'text-nb-gray-400'}`}>
|
||||
{status?.status ?? 'Loading…'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{status.ip && (
|
||||
<>
|
||||
<span className="text-nb-gray-400">IP Address</span>
|
||||
<span className="font-mono">{status.ip}</span>
|
||||
</>
|
||||
)}
|
||||
{status.fqdn && (
|
||||
<>
|
||||
<span className="text-nb-gray-400">Hostname</span>
|
||||
<span className="font-mono">{status.fqdn}</span>
|
||||
</>
|
||||
)}
|
||||
{status.connectedPeers > 0 && (
|
||||
<>
|
||||
<span className="text-nb-gray-400">Connected Peers</span>
|
||||
<span>{status.connectedPeers}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="flex gap-3">
|
||||
{!isConnected && !isConnecting && (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={busy}
|
||||
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{busy ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
)}
|
||||
{(isConnected || isConnecting) && (
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
disabled={busy}
|
||||
className="px-6 py-2.5 bg-nb-gray-800 hover:bg-nb-gray-600 disabled:opacity-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{busy ? 'Disconnecting…' : 'Disconnect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Call } from '@wailsio/runtime'
|
||||
import type { InstallerResult } from '../bindings'
|
||||
|
||||
type UpdateState = 'idle' | 'triggering' | 'polling' | 'success' | 'failed' | 'timeout'
|
||||
|
||||
export default function Update() {
|
||||
const [state, setState] = useState<UpdateState>('idle')
|
||||
const [dots, setDots] = useState('')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Animate dots when polling
|
||||
useEffect(() => {
|
||||
if (state !== 'polling') return
|
||||
let count = 0
|
||||
const id = setInterval(() => {
|
||||
count = (count + 1) % 4
|
||||
setDots('.'.repeat(count))
|
||||
}, 500)
|
||||
return () => clearInterval(id)
|
||||
}, [state])
|
||||
|
||||
async function handleTriggerUpdate() {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
setState('triggering')
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
console.log('[Update] calling services.UpdateService.TriggerUpdate')
|
||||
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.TriggerUpdate')
|
||||
} catch (e) {
|
||||
console.error('[Update] TriggerUpdate error:', e)
|
||||
setErrorMsg(String(e))
|
||||
setState('failed')
|
||||
return
|
||||
}
|
||||
|
||||
setState('polling')
|
||||
|
||||
// Poll for installer result (up to 15 minutes handled server-side)
|
||||
try {
|
||||
console.log('[Update] calling services.UpdateService.GetInstallerResult')
|
||||
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.GetInstallerResult') as InstallerResult
|
||||
console.log('[Update] GetInstallerResult:', JSON.stringify(result))
|
||||
if (result?.success) {
|
||||
setState('success')
|
||||
} else {
|
||||
setErrorMsg(result?.errorMsg ?? 'Update failed')
|
||||
setState('failed')
|
||||
}
|
||||
} catch (e) {
|
||||
// If the daemon restarts, the gRPC call may fail — treat as success
|
||||
setState('success')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-2">Update</h1>
|
||||
<p className="text-nb-gray-400 text-sm mb-8">
|
||||
Trigger an automatic client update managed by the NetBird daemon.
|
||||
</p>
|
||||
|
||||
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 text-center">
|
||||
{state === 'idle' && (
|
||||
<>
|
||||
<p className="text-nb-gray-300 mb-5">Click below to trigger a daemon-managed update.</p>
|
||||
<button
|
||||
onClick={handleTriggerUpdate}
|
||||
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Trigger Update
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'triggering' && (
|
||||
<p className="text-yellow-300 animate-pulse">Triggering update…</p>
|
||||
)}
|
||||
|
||||
{state === 'polling' && (
|
||||
<div>
|
||||
<p className="text-yellow-300 text-lg mb-2">Updating{dots}</p>
|
||||
<p className="text-nb-gray-400 text-sm">The daemon is installing the update. Please wait.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'success' && (
|
||||
<div>
|
||||
<p className="text-green-400 text-lg font-semibold mb-2">Update Successful!</p>
|
||||
<p className="text-nb-gray-300 text-sm">The client has been updated. You may need to restart.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'failed' && (
|
||||
<div>
|
||||
<p className="text-red-400 text-lg font-semibold mb-2">Update Failed</p>
|
||||
{errorMsg && <p className="text-nb-gray-300 text-sm mb-4">{errorMsg}</p>}
|
||||
<button
|
||||
onClick={() => { setState('idle'); setErrorMsg('') }}
|
||||
className="px-4 py-2 text-sm bg-nb-gray-900 hover:bg-nb-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user