White mac native + notification test

This commit is contained in:
Zoltán Papp
2026-03-04 11:12:43 +01:00
parent 04a982263d
commit 1451cedf86
28 changed files with 1234 additions and 740 deletions

View File

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

View File

@@ -1 +0,0 @@
4340708917a9379498dcca2d1bd8c665

View File

@@ -1 +0,0 @@
f88f16cee21f42c24ff6b3c1410b0ddd

View File

@@ -0,0 +1,35 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Builds the application for macOS
cmds:
- task: build:native
vars:
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
build:native:
summary: Builds the application natively on macOS
internal: true
deps:
- task: common:build:frontend
vars:
DEV:
ref: .DEV
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>NetBird</title> <title>NetBird</title>
</head> </head>
<body> <body>

View File

@@ -34,9 +34,15 @@ export default function App() {
return ( return (
<HashRouter> <HashRouter>
<Navigator /> <Navigator />
<div className="min-h-screen bg-nb-gray-DEFAULT text-nb-gray-100 flex"> <div
className="min-h-screen flex"
style={{
backgroundColor: 'var(--color-bg-primary)',
color: 'var(--color-text-primary)',
}}
>
<NavBar /> <NavBar />
<main className="flex-1 p-6 overflow-y-auto h-screen"> <main className="flex-1 px-10 py-8 overflow-y-auto h-screen">
<Routes> <Routes>
<Route path="/" element={<Status />} /> <Route path="/" element={<Status />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />

View File

@@ -1,60 +1,90 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import NetBirdLogo from './NetBirdLogo' import NetBirdLogo from './NetBirdLogo'
const navItems = [ const mainItems = [
{ to: '/', label: 'Status', icon: StatusIcon }, { to: '/', label: 'Status', icon: StatusIcon },
{ to: '/peers', label: 'Peers', icon: PeersIcon }, { to: '/peers', label: 'Peers', icon: PeersIcon },
{ to: '/networks', label: 'Networks', icon: NetworksIcon }, { to: '/networks', label: 'Networks', icon: NetworksIcon },
{ to: '/profiles', label: 'Profiles', icon: ProfilesIcon }, { to: '/profiles', label: 'Profiles', icon: ProfilesIcon },
]
const systemItems = [
{ to: '/settings', label: 'Settings', icon: SettingsIcon }, { to: '/settings', label: 'Settings', icon: SettingsIcon },
{ to: '/debug', label: 'Debug', icon: DebugIcon }, { to: '/debug', label: 'Debug', icon: DebugIcon },
{ to: '/update', label: 'Update', icon: UpdateIcon }, { 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() { export default function NavBar() {
return ( return (
<nav className="w-[15rem] min-w-[15rem] bg-nb-gray-950 border-r border-nb-gray-900 flex flex-col h-screen"> <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 */} {/* Logo */}
<div className="px-5 py-5 border-b border-nb-gray-900"> <div className="px-4 py-4" style={{ borderBottom: '0.5px solid var(--color-separator)' }}>
<NetBirdLogo full /> <NetBirdLogo full />
</div> </div>
{/* Nav items */} {/* Nav items */}
<div className="flex-1 px-3 py-4 space-y-1 overflow-y-auto"> <div className="flex-1 px-2.5 py-3 overflow-y-auto">
{navItems.map((item) => ( <NavGroup items={mainItems} />
<NavLink <div className="my-2 mx-2.5" style={{ borderTop: '0.5px solid var(--color-separator)' }} />
key={item.to} <NavGroup items={systemItems} />
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> </div>
{/* Version footer */} {/* Version footer */}
<div className="px-5 py-3 border-t border-nb-gray-900 text-xs text-nb-gray-500"> <div className="px-4 py-2.5 text-[11px]" style={{ color: 'var(--color-text-quaternary)', borderTop: '0.5px solid var(--color-separator)' }}>
NetBird Client NetBird Client
</div> </div>
</nav> </nav>
) )
} }
/* ── Icons (18px, stroke) ──────────────────────────────────────── */
function StatusIcon({ active }: { active: boolean }) { function StatusIcon({ active }: { active: boolean }) {
return ( 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"> <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" /> <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg> </svg>
) )
@@ -62,7 +92,7 @@ function StatusIcon({ active }: { active: boolean }) {
function PeersIcon({ active }: { active: boolean }) { function PeersIcon({ active }: { active: boolean }) {
return ( 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"> <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" /> <rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" /> <line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" /> <line x1="12" y1="17" x2="12" y2="21" />
@@ -72,7 +102,7 @@ function PeersIcon({ active }: { active: boolean }) {
function NetworksIcon({ active }: { active: boolean }) { function NetworksIcon({ active }: { active: boolean }) {
return ( 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"> <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="12" cy="5" r="2" />
<circle cx="5" cy="19" r="2" /> <circle cx="5" cy="19" r="2" />
<circle cx="19" cy="19" r="2" /> <circle cx="19" cy="19" r="2" />
@@ -84,7 +114,7 @@ function NetworksIcon({ active }: { active: boolean }) {
function ProfilesIcon({ active }: { active: boolean }) { function ProfilesIcon({ active }: { active: boolean }) {
return ( 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"> <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" /> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" /> <circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" /> <path d="M22 21v-2a4 4 0 0 0-3-3.87" />
@@ -95,7 +125,7 @@ function ProfilesIcon({ active }: { active: boolean }) {
function SettingsIcon({ active }: { active: boolean }) { function SettingsIcon({ active }: { active: boolean }) {
return ( 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"> <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" /> <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" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
@@ -104,7 +134,7 @@ function SettingsIcon({ active }: { active: boolean }) {
function DebugIcon({ active }: { active: boolean }) { function DebugIcon({ active }: { active: boolean }) {
return ( 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"> <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="m8 2 1.88 1.88" />
<path d="M14.12 3.88 16 2" /> <path d="M14.12 3.88 16 2" />
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" /> <path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
@@ -122,7 +152,7 @@ function DebugIcon({ active }: { active: boolean }) {
function UpdateIcon({ active }: { active: boolean }) { function UpdateIcon({ active }: { active: boolean }) {
return ( 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"> <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="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 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="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />

View File

@@ -14,7 +14,7 @@ export default function NetBirdLogo({ full = false, className }: { full?: boolea
return ( return (
<div className={`flex items-center gap-2 ${className ?? ''}`}> <div className={`flex items-center gap-2 ${className ?? ''}`}>
<BirdMark /> <BirdMark />
<span className="text-lg font-bold tracking-wide text-nb-gray-100">NETBIRD</span> <span className="text-lg font-bold tracking-wide" style={{ color: 'var(--color-text-primary)' }}>NETBIRD</span>
</div> </div>
) )
} }

View File

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,47 @@
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

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,72 @@
/* 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

@@ -0,0 +1,39 @@
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,7 +1,136 @@
@import "tailwindcss"; @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 { @theme {
--color-netbird-50: #fff6ed; --color-netbird-50: #fff6ed;
--color-netbird-100: #feecd6; --color-netbird-100: #feecd6;
--color-netbird-150: #ffdfb8; --color-netbird-150: #ffdfb8;
--color-netbird-200: #ffd4a6; --color-netbird-200: #ffd4a6;
@@ -15,26 +144,10 @@
--color-netbird-900: #7a2b14; --color-netbird-900: #7a2b14;
--color-netbird-950: #421308; --color-netbird-950: #421308;
--color-nb-gray-DEFAULT: #181a1d; --font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
--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;
} }
/* ── Base ────────────────────────────────────────────────────────── */
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -42,72 +155,32 @@
body { body {
margin: 0; margin: 0;
font-family: var(--font-sans); font-family: var(--font-sans);
background-color: #181a1d; font-size: 13px;
color: #e4e7e9; background-color: var(--color-bg-primary);
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
#root { #root {
min-height: 100vh; min-height: 100vh;
} }
/* Toggle switch — matches dashboard ToggleSwitch.tsx */ /* ── Scrollbar (macOS-like thin) ────────────────────────────────── */
.toggle-track { ::-webkit-scrollbar {
position: relative; width: 8px;
display: inline-flex; height: 8px;
flex-shrink: 0; }
width: 44px; ::-webkit-scrollbar-track {
height: 24px; background: transparent;
border-radius: 9999px; }
::-webkit-scrollbar-thumb {
background: var(--color-text-quaternary);
border-radius: 4px;
border: 2px solid transparent; border: 2px solid transparent;
cursor: pointer; background-clip: content-box;
transition: background-color 0.2s;
background-color: var(--color-nb-gray-700);
} }
.toggle-track[aria-checked="true"] { ::-webkit-scrollbar-thumb:hover {
background-color: var(--color-netbird-400); background: var(--color-text-tertiary);
} background-clip: content-box;
.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);
} }

View File

@@ -1,6 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { Call } from '@wailsio/runtime' import { Call } from '@wailsio/runtime'
import type { DebugBundleParams, DebugBundleResult } from '../bindings' 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' const DEFAULT_UPLOAD_URL = 'https://upload.netbird.io'
@@ -26,7 +31,7 @@ export default function Debug() {
setRunning(true) setRunning(true)
setError(null) setError(null)
setResult(null) setResult(null)
setProgress(runForDuration ? `Running with trace logs for ${durationMins} minute(s)` : 'Creating debug bundle') setProgress(runForDuration ? `Running with trace logs for ${durationMins} minute(s)\u2026` : 'Creating debug bundle\u2026')
const params: DebugBundleParams = { const params: DebugBundleParams = {
anonymize, anonymize,
@@ -56,114 +61,122 @@ export default function Debug() {
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-2">Debug</h1> <h1 className="text-xl font-semibold mb-1" style={{ color: 'var(--color-text-primary)' }}>Debug</h1>
<p className="text-nb-gray-400 text-sm mb-6"> <p className="text-[13px] mb-6" style={{ color: 'var(--color-text-secondary)' }}>
Create a debug bundle to help troubleshoot issues with NetBird. Create a debug bundle to help troubleshoot issues with NetBird.
</p> </p>
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 space-y-5"> <Card label="OPTIONS" className="mb-5">
<Toggle label="Anonymize sensitive information (IPs, domains…)" checked={anonymize} onChange={setAnonymize} /> <CardRow label="Anonymize sensitive information">
<Toggle label="Include system information (routes, interfaces…)" checked={systemInfo} onChange={setSystemInfo} /> <Toggle checked={anonymize} onChange={setAnonymize} />
<Toggle label="Upload bundle automatically after creation" checked={upload} onChange={setUpload} /> </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 && ( {upload && (
<div> <Card label="UPLOAD" className="mb-5">
<label className="block text-sm text-nb-gray-400 mb-1.5">Upload URL</label> <CardRow label="Upload URL">
<input <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} value={uploadUrl}
onChange={e => setUploadUrl(e.target.value)} onChange={e => setUploadUrl(e.target.value)}
disabled={running} disabled={running}
style={{ width: 240 }}
/> />
</div> </CardRow>
)} </Card>
)}
<div className="border-t border-nb-gray-900 pt-4"> <Card label="TRACE LOGGING" className="mb-5">
<Toggle <CardRow label="Run with trace logs before creating bundle">
label="Run with trace logs before creating bundle" <Toggle checked={runForDuration} onChange={setRunForDuration} />
checked={runForDuration} </CardRow>
onChange={setRunForDuration} {runForDuration && (
/> <CardRow label="Duration">
{runForDuration && ( <div className="flex items-center gap-2">
<div className="mt-3 flex items-center gap-2"> <Input
<span className="text-sm text-nb-gray-400">for</span>
<input
type="number" type="number"
min={1} min={1}
max={60} max={60}
value={durationMins} value={durationMins}
onChange={e => setDurationMins(Math.max(1, parseInt(e.target.value) || 1))} onChange={e => setDurationMins(Math.max(1, parseInt(e.target.value) || 1))}
disabled={running} 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" style={{ width: 64, textAlign: 'center' }}
/> />
<span className="text-sm text-nb-gray-400">{durationMins === 1 ? 'minute' : 'minutes'}</span> <span className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>
{durationMins === 1 ? 'minute' : 'minutes'}
</span>
</div> </div>
)} </CardRow>
{runForDuration && ( )}
<p className="text-xs text-nb-gray-500 mt-2"> {runForDuration && (
Note: NetBird will be brought up and down during collection. <div className="px-4 py-2 text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
</p> Note: NetBird will be brought up and down during collection.
)} </div>
</div> )}
</div> </Card>
{error && ( {error && (
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm"> <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} {error}
</div> </div>
)} )}
{progress && ( {progress && (
<div className="mt-4 p-3 bg-nb-gray-920 border border-nb-gray-900 rounded-lg text-sm"> <div
<span className={running ? 'animate-pulse text-yellow-300' : 'text-green-400'}>{progress}</span> 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> </div>
)} )}
{result && ( {result && (
<div className="mt-4 bg-nb-gray-920 rounded-xl p-5 border border-nb-gray-900 text-sm space-y-2"> <Card className="mb-4">
{result.uploadedKey ? ( <div className="px-4 py-3 space-y-2 text-[13px]">
<> {result.uploadedKey ? (
<p className="text-green-400 font-medium">Bundle uploaded successfully!</p> <>
<div className="flex items-center gap-2"> <p style={{ color: 'var(--color-status-green)' }} className="font-medium">Bundle uploaded successfully!</p>
<span className="text-nb-gray-400">Upload key:</span> <div className="flex items-center gap-2">
<code className="bg-nb-gray-900 px-2 py-0.5 rounded text-xs font-mono">{result.uploadedKey}</code> <span style={{ color: 'var(--color-text-secondary)' }}>Upload key:</span>
</div> <code
</> className="px-2 py-0.5 rounded text-[12px] font-mono"
) : result.uploadFailureReason ? ( style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
<p className="text-yellow-400">Upload failed: {result.uploadFailureReason}</p> >
) : null} {result.uploadedKey}
<div className="flex items-center gap-2"> </code>
<span className="text-nb-gray-400">Local path:</span> </div>
<code className="bg-nb-gray-900 px-2 py-0.5 rounded text-xs font-mono break-all">{result.localPath}</code> </>
) : 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> </div>
</div> </Card>
)} )}
<div className="mt-4"> <Button onClick={handleCreate} disabled={running}>
<button {running ? 'Running\u2026' : 'Create Debug Bundle'}
onClick={handleCreate} </Button>
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> </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>
)
}

View File

@@ -1,6 +1,11 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { Call } from '@wailsio/runtime' import { Call } from '@wailsio/runtime'
import type { NetworkInfo } from '../bindings' 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'
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.NetworkService' const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.NetworkService'
@@ -8,6 +13,12 @@ type Tab = 'all' | 'overlapping' | 'exit-node'
type SortKey = 'id' | 'range' type SortKey = 'id' | 'range'
type SortDir = 'asc' | 'desc' 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() { export default function Networks() {
const [networks, setNetworks] = useState<NetworkInfo[]>([]) const [networks, setNetworks] = useState<NetworkInfo[]>([])
const [tab, setTab] = useState<Tab>('all') const [tab, setTab] = useState<Tab>('all')
@@ -96,95 +107,68 @@ export default function Networks() {
return ( return (
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Networks</h1> <h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Networks</h1>
{/* Tabs */} <SegmentedControl options={tabOptions} value={tab} onChange={setTab} className="mb-5" />
<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 */} {/* Toolbar */}
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-sm"> <SearchInput
<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}> value={search}
<circle cx={11} cy={11} r={8} /><path d="m21 21-4.3-4.3" /> onChange={setSearch}
</svg> placeholder="Search by name, range or domain..."
<input className="flex-1 max-w-sm"
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"> <div className="flex gap-2 ml-auto">
<ActionButton onClick={selectAll}>Select All</ActionButton> <Button variant="secondary" size="sm" onClick={selectAll}>Select All</Button>
<ActionButton onClick={deselectAll}>Deselect All</ActionButton> <Button variant="secondary" size="sm" onClick={deselectAll}>Deselect All</Button>
<ActionButton onClick={load}> <Button variant="secondary" size="sm" onClick={load}>Refresh</Button>
<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>
</div> </div>
{error && ( {error && (
<div className="mb-4 p-3 bg-red-950/40 border border-red-500 rounded-md text-red-400 text-xs"> <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} {error}
</div> </div>
)} )}
{/* Selection summary */}
{selectedCount > 0 && ( {selectedCount > 0 && (
<div className="mb-3 text-xs text-nb-gray-400"> <div className="mb-3 text-[12px]" style={{ color: 'var(--color-text-tertiary)' }}>
{selectedCount} of {networks.length} network{networks.length !== 1 ? 's' : ''} selected {selectedCount} of {networks.length} network{networks.length !== 1 ? 's' : ''} selected
</div> </div>
)} )}
{/* Table */}
{loading && networks.length === 0 ? ( {loading && networks.length === 0 ? (
<TableSkeleton /> <TableSkeleton />
) : filtered.length === 0 && networks.length === 0 ? ( ) : filtered.length === 0 && networks.length === 0 ? (
<EmptyState tab={tab} /> <EmptyState tab={tab} />
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="py-12 text-center text-nb-gray-400 text-sm"> <div className="py-12 text-center text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>
No networks match your search. No networks match your search.
<button onClick={() => setSearch('')} className="ml-2 text-netbird hover:underline">Clear search</button> <button onClick={() => setSearch('')} className="ml-2 hover:underline" style={{ color: 'var(--color-accent)' }}>Clear search</button>
</div> </div>
) : ( ) : (
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden"> <TableContainer>
<table className="w-full text-sm"> <table className="w-full text-[13px]">
<thead> <TableHeader>
<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="Network" sortKey="id" currentKey={sortKey} dir={sortDir} onSort={toggleSort} /> <SortableHeader label="Range / Domains" sortKey="range" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="Range / Domains" sortKey="range" currentKey={sortKey} dir={sortDir} onSort={toggleSort} /> <TableHeaderCell>Resolved IPs</TableHeaderCell>
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Resolved IPs</th> <TableHeaderCell className="w-20">Active</TableHeaderCell>
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide w-20">Active</th> </TableHeader>
</tr>
</thead>
<tbody> <tbody>
{filtered.map(n => ( {filtered.map(n => (
<NetworkRow key={n.id} network={n} onToggle={() => toggle(n.id, n.selected)} /> <NetworkRow key={n.id} network={n} onToggle={() => toggle(n.id, n.selected)} />
))} ))}
</tbody> </tbody>
</table> </table>
<TableFooter>
{/* 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' : ''} Showing {filtered.length} of {networks.length} network{networks.length !== 1 ? 's' : ''}
</div> </TableFooter>
</div> </TableContainer>
)} )}
</div> </div>
) )
@@ -198,80 +182,78 @@ function NetworkRow({ network, onToggle }: { network: NetworkInfo; onToggle: ()
const hasDomains = domains.length > 0 const hasDomains = domains.length > 0
return ( return (
<tr className="border-b border-nb-gray-900/40 hover:bg-nb-gray-940 transition-colors group/row"> <TableRow>
{/* Network name cell (dashboard-style icon square + name) */} <TableCell>
<td className="px-4 py-3 align-middle">
<div className="flex items-center gap-3 min-w-[180px]"> <div className="flex items-center gap-3 min-w-[180px]">
<NetworkSquare name={network.id} active={network.selected} /> <NetworkSquare name={network.id} active={network.selected} />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-sm text-nb-gray-100">{network.id}</span> <span className="font-medium text-[13px]" style={{ color: 'var(--color-text-primary)' }}>{network.id}</span>
{hasDomains && domains.length > 1 && ( {hasDomains && domains.length > 1 && (
<span className="text-xs text-nb-gray-500 mt-0.5">{domains.length} domains</span> <span className="text-[11px] mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>{domains.length} domains</span>
)} )}
</div> </div>
</div> </div>
</td> </TableCell>
{/* Range / Domains */} <TableCell>
<td className="px-4 py-3 align-middle">
{hasDomains ? ( {hasDomains ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{domains.slice(0, 2).map(d => ( {domains.slice(0, 2).map(d => (
<span key={d} className="font-mono text-[0.82rem] text-nb-gray-300">{d}</span> <span key={d} className="font-mono text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>{d}</span>
))} ))}
{domains.length > 2 && ( {domains.length > 2 && (
<span className="text-xs text-nb-gray-500" title={domains.join(', ')}>+{domains.length - 2} more</span> <span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }} title={domains.join(', ')}>+{domains.length - 2} more</span>
)} )}
</div> </div>
) : ( ) : (
<span className="font-mono text-[0.82rem] text-nb-gray-300">{network.range}</span> <span className="font-mono text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>{network.range}</span>
)} )}
</td> </TableCell>
{/* Resolved IPs */} <TableCell>
<td className="px-4 py-3 align-middle">
{resolvedEntries.length > 0 ? ( {resolvedEntries.length > 0 ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{resolvedEntries.slice(0, 2).map(([domain, ips]) => ( {resolvedEntries.slice(0, 2).map(([domain, ips]) => (
<span key={domain} className="font-mono text-xs text-nb-gray-400" title={`${domain}: ${ips.join(', ')}`}> <span key={domain} className="font-mono text-[11px]" style={{ color: 'var(--color-text-tertiary)' }} title={`${domain}: ${ips.join(', ')}`}>
{ips[0]}{ips.length > 1 && <span className="text-nb-gray-600"> +{ips.length - 1}</span>} {ips[0]}{ips.length > 1 && <span style={{ color: 'var(--color-text-quaternary)' }}> +{ips.length - 1}</span>}
</span> </span>
))} ))}
{resolvedEntries.length > 2 && ( {resolvedEntries.length > 2 && (
<span className="text-xs text-nb-gray-600">+{resolvedEntries.length - 2} more</span> <span className="text-[11px]" style={{ color: 'var(--color-text-quaternary)' }}>+{resolvedEntries.length - 2} more</span>
)} )}
</div> </div>
) : ( ) : (
<span className="text-nb-gray-600"></span> <span style={{ color: 'var(--color-text-quaternary)' }}>{'\u2014'}</span>
)} )}
</td> </TableCell>
{/* Active toggle */} <TableCell>
<td className="px-4 py-3 align-middle"> <Toggle checked={network.selected} onChange={onToggle} small />
<button </TableCell>
role="switch" </TableRow>
aria-checked={network.selected}
onClick={onToggle}
className="toggle-track-sm"
>
<span className="toggle-thumb-sm" />
</button>
</td>
</tr>
) )
} }
/* ---- Network Icon Square (matches dashboard NetworkInformationSquare) ---- */ /* ---- Network Icon Square ---- */
function NetworkSquare({ name, active }: { name: string; active: boolean }) { function NetworkSquare({ name, active }: { name: string; active: boolean }) {
const initials = name.substring(0, 2).toUpperCase() const initials = name.substring(0, 2).toUpperCase()
return ( 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"> <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} {initials}
{/* Status dot */} <span
<div className={`absolute bottom-0 right-0 h-2 w-2 rounded-full z-10 ${active ? 'bg-green-500' : 'bg-nb-gray-700'}`} /> className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full"
{/* Corner mask for rounded dot cutout */} style={{
<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" /> backgroundColor: active ? 'var(--color-status-green)' : 'var(--color-status-gray)',
border: '2px solid var(--color-bg-secondary)',
}}
/>
</div> </div>
) )
} }
@@ -283,10 +265,7 @@ function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
}) { }) {
const isActive = currentKey === sortKey const isActive = currentKey === sortKey
return ( return (
<th <TableHeaderCell onClick={() => onSort(sortKey)}>
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"> <span className="inline-flex items-center gap-1">
{label} {label}
{isActive && ( {isActive && (
@@ -295,20 +274,7 @@ function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
</svg> </svg>
)} )}
</span> </span>
</th> </TableHeaderCell>
)
}
/* ---- 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>
) )
} }
@@ -322,13 +288,22 @@ function EmptyState({ tab }: { tab: Tab }) {
: 'No networks found.' : 'No networks found.'
return ( 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
<div className="h-12 w-12 rounded-lg bg-nb-gray-800 flex items-center justify-center"> className="rounded-[var(--radius-card)] py-16 flex flex-col items-center gap-3"
<svg className="w-6 h-6 text-nb-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> 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" /> <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> </svg>
</div> </div>
<p className="text-sm text-nb-gray-400">{msg}</p> <p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>{msg}</p>
</div> </div>
) )
} }
@@ -337,18 +312,24 @@ function EmptyState({ tab }: { tab: Tab }) {
function TableSkeleton() { function TableSkeleton() {
return ( return (
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden"> <div
<div className="bg-nb-gray-900 h-11" /> 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) => ( {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
<div className="w-4 h-4 rounded bg-nb-gray-800" /> 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="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-md bg-nb-gray-800" /> <div className="w-10 h-10 rounded-[var(--radius-control)]" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-24 rounded bg-nb-gray-800" /> <div className="h-4 w-24 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div> </div>
<div className="h-4 w-32 rounded bg-nb-gray-800" /> <div className="h-4 w-32 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-20 rounded bg-nb-gray-800" /> <div className="h-4 w-20 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-6 w-16 rounded-md bg-nb-gray-800" /> <div className="h-6 w-12 rounded-full" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,6 +1,10 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { Call } from '@wailsio/runtime' import { Call } from '@wailsio/runtime'
import type { PeerInfo } from '../bindings' 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'
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.PeersService' const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.PeersService'
@@ -15,7 +19,7 @@ function formatBytes(bytes: number): string {
} }
function formatLatency(ms: number): string { function formatLatency(ms: number): string {
if (ms <= 0) return '' if (ms <= 0) return '\u2014'
if (ms < 1) return '<1 ms' if (ms < 1) return '<1 ms'
return `${ms.toFixed(1)} ms` return `${ms.toFixed(1)} ms`
} }
@@ -89,79 +93,66 @@ export default function Peers() {
return ( return (
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Peers</h1> <h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Peers</h1>
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-sm"> <SearchInput
<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}> value={search}
<circle cx={11} cy={11} r={8} /><path d="m21 21-4.3-4.3" /> onChange={setSearch}
</svg> placeholder="Search by name, IP or status..."
<input className="flex-1 max-w-sm"
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"> <div className="flex gap-2 ml-auto">
<ActionButton onClick={load}> <Button variant="secondary" size="sm" onClick={load}>Refresh</Button>
<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>
</div> </div>
{error && ( {error && (
<div className="mb-4 p-3 bg-red-950/40 border border-red-500 rounded-md text-red-400 text-xs"> <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} {error}
</div> </div>
)} )}
{/* Summary */}
{peers.length > 0 && ( {peers.length > 0 && (
<div className="mb-3 text-xs text-nb-gray-400"> <div className="mb-3 text-[12px]" style={{ color: 'var(--color-text-tertiary)' }}>
{connectedCount} of {peers.length} peer{peers.length !== 1 ? 's' : ''} connected {connectedCount} of {peers.length} peer{peers.length !== 1 ? 's' : ''} connected
</div> </div>
)} )}
{/* Table */}
{loading && peers.length === 0 ? ( {loading && peers.length === 0 ? (
<TableSkeleton /> <TableSkeleton />
) : peers.length === 0 ? ( ) : peers.length === 0 ? (
<EmptyState /> <EmptyState />
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="py-12 text-center text-nb-gray-400 text-sm"> <div className="py-12 text-center text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>
No peers match your search. No peers match your search.
<button onClick={() => setSearch('')} className="ml-2 text-netbird hover:underline">Clear search</button> <button onClick={() => setSearch('')} className="ml-2 hover:underline" style={{ color: 'var(--color-accent)' }}>Clear search</button>
</div> </div>
) : ( ) : (
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden"> <TableContainer>
<table className="w-full text-sm"> <table className="w-full text-[13px]">
<thead> <TableHeader>
<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="Peer" sortKey="fqdn" currentKey={sortKey} dir={sortDir} onSort={toggleSort} /> <SortableHeader label="IP" sortKey="ip" 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} />
<SortableHeader label="Status" sortKey="status" currentKey={sortKey} dir={sortDir} onSort={toggleSort} /> <TableHeaderCell>Connection</TableHeaderCell>
<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} />
<SortableHeader label="Latency" sortKey="latency" currentKey={sortKey} dir={sortDir} onSort={toggleSort} /> <TableHeaderCell>Transfer</TableHeaderCell>
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Transfer</th> </TableHeader>
</tr>
</thead>
<tbody> <tbody>
{filtered.map(p => ( {filtered.map(p => (
<PeerRow key={p.pubKey} peer={p} /> <PeerRow key={p.pubKey} peer={p} />
))} ))}
</tbody> </tbody>
</table> </table>
<TableFooter>
<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' : ''} Showing {filtered.length} of {peers.length} peer{peers.length !== 1 ? 's' : ''}
</div> </TableFooter>
</div> </TableContainer>
)} )}
</div> </div>
) )
@@ -174,79 +165,73 @@ function PeerRow({ peer }: { peer: PeerInfo }) {
const connected = peer.connStatus === 'Connected' const connected = peer.connStatus === 'Connected'
return ( return (
<tr className="border-b border-nb-gray-900/40 hover:bg-nb-gray-940 transition-colors group/row"> <TableRow>
{/* Peer name */} <TableCell>
<td className="px-4 py-3 align-middle">
<div className="flex items-center gap-3 min-w-[160px]"> <div className="flex items-center gap-3 min-w-[160px]">
<PeerSquare name={name} connected={connected} /> <PeerSquare name={name} connected={connected} />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-sm text-nb-gray-100 truncate max-w-[200px]" title={peer.fqdn}>{name}</span> <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 && ( {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> <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>
</div> </div>
</td> </TableCell>
{/* IP */} <TableCell>
<td className="px-4 py-3 align-middle"> <span className="font-mono text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>{peer.ip || '\u2014'}</span>
<span className="font-mono text-[0.82rem] text-nb-gray-300">{peer.ip || '—'}</span> </TableCell>
</td>
{/* Status */} <TableCell>
<td className="px-4 py-3 align-middle">
<StatusBadge status={peer.connStatus} /> <StatusBadge status={peer.connStatus} />
</td> </TableCell>
{/* Connection type */} <TableCell>
<td className="px-4 py-3 align-middle">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{connected ? ( {connected ? (
<> <>
<span className="text-xs text-nb-gray-300"> <span className="text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>
{peer.relayed ? 'Relayed' : 'Direct'}{' '} {peer.relayed ? 'Relayed' : 'Direct'}{' '}
{peer.rosenpassEnabled && ( {peer.rosenpassEnabled && (
<span className="text-green-400" title="Rosenpass post-quantum security enabled">PQ</span> <span style={{ color: 'var(--color-status-green)' }} title="Rosenpass post-quantum security enabled">PQ</span>
)} )}
</span> </span>
{peer.relayed && peer.relayAddress && ( {peer.relayed && peer.relayAddress && (
<span className="text-xs text-nb-gray-500 font-mono" title={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} via {peer.relayAddress.length > 24 ? peer.relayAddress.substring(0, 24) + '...' : peer.relayAddress}
</span> </span>
)} )}
{!peer.relayed && peer.localIceType && ( {!peer.relayed && peer.localIceType && (
<span className="text-xs text-nb-gray-500">{peer.localIceType} / {peer.remoteIceType}</span> <span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>{peer.localIceType} / {peer.remoteIceType}</span>
)} )}
</> </>
) : ( ) : (
<span className="text-nb-gray-600"></span> <span style={{ color: 'var(--color-text-quaternary)' }}>{'\u2014'}</span>
)} )}
</div> </div>
</td> </TableCell>
{/* Latency */} <TableCell>
<td className="px-4 py-3 align-middle"> <span className="text-[13px]" style={{ color: peer.latencyMs > 0 ? 'var(--color-text-secondary)' : 'var(--color-text-quaternary)' }}>
<span className={`text-sm ${peer.latencyMs > 0 ? 'text-nb-gray-300' : 'text-nb-gray-600'}`}>
{formatLatency(peer.latencyMs)} {formatLatency(peer.latencyMs)}
</span> </span>
</td> </TableCell>
{/* Transfer */} <TableCell>
<td className="px-4 py-3 align-middle">
{(peer.bytesRx > 0 || peer.bytesTx > 0) ? ( {(peer.bytesRx > 0 || peer.bytesTx > 0) ? (
<div className="flex flex-col gap-0.5 text-xs"> <div className="flex flex-col gap-0.5 text-[11px]">
<span className="text-nb-gray-400"> <span style={{ color: 'var(--color-text-tertiary)' }}>
<span className="text-green-400/70" title="Received">&#8595;</span> {formatBytes(peer.bytesRx)} <span style={{ color: 'var(--color-status-green)' }} title="Received">&#8595;</span> {formatBytes(peer.bytesRx)}
</span> </span>
<span className="text-nb-gray-400"> <span style={{ color: 'var(--color-text-tertiary)' }}>
<span className="text-blue-400/70" title="Sent">&#8593;</span> {formatBytes(peer.bytesTx)} <span style={{ color: 'var(--color-accent)' }} title="Sent">&#8593;</span> {formatBytes(peer.bytesTx)}
</span> </span>
</div> </div>
) : ( ) : (
<span className="text-nb-gray-600"></span> <span style={{ color: 'var(--color-text-quaternary)' }}>{'\u2014'}</span>
)} )}
</td> </TableCell>
</tr> </TableRow>
) )
} }
@@ -255,30 +240,25 @@ function PeerRow({ peer }: { peer: PeerInfo }) {
function PeerSquare({ name, connected }: { name: string; connected: boolean }) { function PeerSquare({ name, connected }: { name: string; connected: boolean }) {
const initials = name.substring(0, 2).toUpperCase() const initials = name.substring(0, 2).toUpperCase()
return ( 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"> <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} {initials}
<div className={`absolute bottom-0 right-0 h-2 w-2 rounded-full z-10 ${connected ? 'bg-green-500' : 'bg-nb-gray-700'}`} /> <span
<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" /> 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> </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 ---- */ /* ---- Sortable Header ---- */
function SortableHeader({ label, sortKey, currentKey, dir, onSort }: { function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
@@ -286,10 +266,7 @@ function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
}) { }) {
const isActive = currentKey === sortKey const isActive = currentKey === sortKey
return ( return (
<th <TableHeaderCell onClick={() => onSort(sortKey)}>
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"> <span className="inline-flex items-center gap-1">
{label} {label}
{isActive && ( {isActive && (
@@ -298,20 +275,7 @@ function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
</svg> </svg>
)} )}
</span> </span>
</th> </TableHeaderCell>
)
}
/* ---- 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>
) )
} }
@@ -319,13 +283,22 @@ function ActionButton({ onClick, children }: { onClick: () => void; children: Re
function EmptyState() { function EmptyState() {
return ( 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
<div className="h-12 w-12 rounded-lg bg-nb-gray-800 flex items-center justify-center"> className="rounded-[var(--radius-card)] py-16 flex flex-col items-center gap-3"
<svg className="w-6 h-6 text-nb-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> 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" /> <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> </svg>
</div> </div>
<p className="text-sm text-nb-gray-400">No peers found. Connect to a network to see peers.</p> <p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>No peers found. Connect to a network to see peers.</p>
</div> </div>
) )
} }
@@ -334,19 +307,26 @@ function EmptyState() {
function TableSkeleton() { function TableSkeleton() {
return ( return (
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden"> <div
<div className="bg-nb-gray-900 h-11" /> 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) => ( {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
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="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-md bg-nb-gray-800" /> <div className="w-10 h-10 rounded-[var(--radius-control)]" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-28 rounded bg-nb-gray-800" /> <div className="h-4 w-28 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div> </div>
<div className="h-4 w-24 rounded bg-nb-gray-800" /> <div className="h-4 w-24 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-5 w-20 rounded-full bg-nb-gray-800" /> <div className="h-5 w-20 rounded-full" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-16 rounded bg-nb-gray-800" /> <div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-14 rounded bg-nb-gray-800" /> <div className="h-4 w-14 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-16 rounded bg-nb-gray-800" /> <div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,6 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Call } from '@wailsio/runtime' import { Call } from '@wailsio/runtime'
import type { ProfileInfo } from '../bindings' 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() { export default function Profiles() {
const [profiles, setProfiles] = useState<ProfileInfo[]>([]) const [profiles, setProfiles] = useState<ProfileInfo[]>([])
@@ -63,101 +68,103 @@ export default function Profiles() {
} }
} }
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 ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Profiles</h1> <h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Profiles</h1>
{error && ( {error && (
<div className="mb-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm"> <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} {error}
</div> </div>
)} )}
{info && ( {info && (
<div className="mb-4 p-3 bg-green-900/50 border border-green-700 rounded-lg text-green-300 text-sm"> <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} {info}
</div> </div>
)} )}
{/* Confirm dialog */}
{confirm && ( {confirm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <Modal
<div className="bg-nb-gray-920 rounded-xl p-6 max-w-sm w-full mx-4 border border-nb-gray-900"> title={confirmTitle()}
<h2 className="text-lg font-semibold mb-2 capitalize"> message={confirmMessage()}
{confirm.action === 'switch' ? 'Switch Profile' : confirm.action === 'remove' ? 'Remove Profile' : 'Deregister Profile'} destructive={confirm.action === 'remove'}
</h2> loading={loading}
<p className="text-nb-gray-300 text-sm mb-5"> onConfirm={handleConfirm}
{confirm.action === 'switch' && `Switch to profile '${confirm.profile}'?`} onCancel={() => setConfirm(null)}
{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 */} {/* Profile list */}
<div className="bg-nb-gray-920 rounded-xl border border-nb-gray-900 overflow-hidden mb-6"> <Card label="PROFILES" className="mb-6">
{profiles.length === 0 ? ( {profiles.length === 0 ? (
<div className="p-4 text-nb-gray-400 text-sm">No profiles found.</div> <div className="p-4 text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>No profiles found.</div>
) : ( ) : (
profiles.map(p => ( 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"> <CardRow key={p.name} label={p.name}>
<span className="text-green-400 w-5 text-center"> <div className="flex items-center gap-2">
{p.isActive ? '✓' : ''} {p.isActive && (
</span> <span
<span className="flex-1 font-medium">{p.name}</span> className="text-[11px] px-2 py-0.5 rounded-full font-medium"
{p.isActive && <span className="text-xs text-netbird px-2 py-0.5 bg-netbird-950/40 rounded-full">Active</span>} style={{
<div className="flex gap-2"> backgroundColor: 'var(--color-status-green-bg)',
{!p.isActive && ( color: 'var(--color-status-green)',
<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 Active
</button> </span>
)} )}
<button {!p.isActive && (
onClick={() => setConfirm({ action: 'logout', profile: p.name })} <Button variant="primary" size="sm" onClick={() => setConfirm({ action: 'switch', profile: p.name })}>
className="px-3 py-1 text-xs bg-nb-gray-900 hover:bg-nb-gray-800 rounded transition-colors" Select
> </Button>
)}
<Button variant="secondary" size="sm" onClick={() => setConfirm({ action: 'logout', profile: p.name })}>
Deregister Deregister
</button> </Button>
<button <Button variant="destructive" size="sm" onClick={() => setConfirm({ action: 'remove', profile: p.name })}>
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 Remove
</button> </Button>
</div> </div>
</div> </CardRow>
)) ))
)} )}
</div> </Card>
{/* Add new profile */} {/* Add new profile */}
<div className="flex gap-3"> <Card label="ADD PROFILE">
<input <div className="flex items-center gap-3 px-4 py-3">
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" <Input
placeholder="New profile name" className="flex-1"
value={newName} placeholder="New profile name"
onChange={e => setNewName(e.target.value)} value={newName}
onKeyDown={e => e.key === 'Enter' && handleAdd()} onChange={e => setNewName(e.target.value)}
/> onKeyDown={e => e.key === 'Enter' && handleAdd()}
<button />
onClick={handleAdd} <Button onClick={handleAdd} disabled={!newName.trim() || loading} size="sm">
disabled={!newName.trim() || loading} Add
className="px-4 py-2 text-sm bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg transition-colors" </Button>
> </div>
Add Profile </Card>
</button>
</div>
</div> </div>
) )
} }

View File

@@ -1,6 +1,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Call } from '@wailsio/runtime' import { Call } from '@wailsio/runtime'
import type { ConfigInfo } from '../bindings' 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> { async function getConfig(): Promise<ConfigInfo | null> {
try { try {
@@ -21,6 +27,12 @@ async function setConfig(cfg: ConfigInfo): Promise<void> {
type Tab = 'connection' | 'network' | 'security' 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() { export default function Settings() {
const [config, setConfigState] = useState<ConfigInfo | null>(null) const [config, setConfigState] = useState<ConfigInfo | null>(null)
const [tab, setTab] = useState<Tab>('connection') const [tab, setTab] = useState<Tab>('connection')
@@ -53,160 +65,111 @@ export default function Settings() {
} }
if (!config) { if (!config) {
return <div className="text-nb-gray-400">Loading settings</div> return <div style={{ color: 'var(--color-text-secondary)' }}>Loading settings\u2026</div>
} }
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Settings</h1> <h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Settings</h1>
{/* Tabs */} <SegmentedControl options={tabOptions} value={tab} onChange={setTab} className="mb-6" />
<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' && (
<>
{tab === 'connection' && ( <Card label="SERVER CONFIGURATION" className="mb-5">
<> <CardRow label="Management URL">
<Field label="Management URL"> <Input
<input
className="input"
value={config.managementUrl} value={config.managementUrl}
onChange={e => update('managementUrl', e.target.value)} onChange={e => update('managementUrl', e.target.value)}
placeholder="https://api.netbird.io:443" placeholder="https://api.netbird.io:443"
style={{ width: 240 }}
/> />
</Field> </CardRow>
<Field label="Admin URL"> <CardRow label="Admin URL">
<input <Input
className="input"
value={config.adminUrl} value={config.adminUrl}
onChange={e => update('adminUrl', e.target.value)} onChange={e => update('adminUrl', e.target.value)}
style={{ width: 240 }}
/> />
</Field> </CardRow>
<Field label="Pre-shared Key"> <CardRow label="Pre-shared Key">
<input <Input
className="input"
type="password" type="password"
value={config.preSharedKey} value={config.preSharedKey}
onChange={e => update('preSharedKey', e.target.value)} onChange={e => update('preSharedKey', e.target.value)}
placeholder="Leave empty to clear" placeholder="Leave empty to clear"
style={{ width: 240 }}
/> />
</Field> </CardRow>
<Toggle </Card>
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' && ( <Card label="BEHAVIOR" className="mb-5">
<> <CardRow label="Connect automatically">
<Field label="Interface Name"> <Toggle checked={!config.disableAutoConnect} onChange={v => update('disableAutoConnect', !v)} />
<input </CardRow>
className="input" <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} value={config.interfaceName}
onChange={e => update('interfaceName', e.target.value)} onChange={e => update('interfaceName', e.target.value)}
placeholder="netbird0" placeholder="netbird0"
style={{ width: 180 }}
/> />
</Field> </CardRow>
<Field label="WireGuard Port"> <CardRow label="WireGuard Port">
<input <Input
className="input"
type="number" type="number"
min={1} min={1}
max={65535} max={65535}
value={config.wireguardPort} value={config.wireguardPort}
onChange={e => update('wireguardPort', parseInt(e.target.value) || 0)} onChange={e => update('wireguardPort', parseInt(e.target.value) || 0)}
placeholder="51820" placeholder="51820"
style={{ width: 100 }}
/> />
</Field> </CardRow>
<Toggle </Card>
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' && ( <Card label="OPTIONS" className="mb-5">
<> <CardRow label="Lazy connections" description="Experimental">
<Toggle <Toggle checked={config.lazyConnectionEnabled} onChange={v => update('lazyConnectionEnabled', v)} />
label="Allow SSH connections" </CardRow>
checked={config.serverSshAllowed} <CardRow label="Block inbound connections">
onChange={v => update('serverSshAllowed', v)} <Toggle checked={config.blockInbound} onChange={v => update('blockInbound', v)} />
/> </CardRow>
<Toggle </Card>
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"> {tab === 'security' && (
<button <Card label="SECURITY" className="mb-5">
onClick={handleSave} <CardRow label="Allow SSH connections">
disabled={saving} <Toggle checked={config.serverSshAllowed} onChange={v => update('serverSshAllowed', v)} />
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors" </CardRow>
> <CardRow label="Rosenpass post-quantum security">
{saving ? 'Saving…' : 'Save'} <Toggle checked={config.rosenpassEnabled} onChange={v => update('rosenpassEnabled', v)} />
</button> </CardRow>
{saved && <span className="text-green-400 text-sm">Saved!</span>} <CardRow label="Rosenpass permissive mode">
{error && <span className="text-red-400 text-sm">{error}</span>} <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>
</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>
)
}

View File

@@ -1,6 +1,9 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Events, Call } from '@wailsio/runtime' import { Events, Call } from '@wailsio/runtime'
import type { StatusInfo } from '../bindings' 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> { async function getStatus(): Promise<StatusInfo | null> {
try { try {
@@ -24,21 +27,21 @@ async function disconnect(): Promise<void> {
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Disconnect') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Disconnect')
} }
function statusColor(status: string): string { function statusDotColor(status: string): string {
switch (status) { switch (status) {
case 'Connected': return 'text-green-400' case 'Connected': return 'var(--color-status-green)'
case 'Connecting': return 'text-yellow-400' case 'Connecting': return 'var(--color-status-yellow)'
case 'Disconnected': return 'text-nb-gray-400' case 'Disconnected': return 'var(--color-status-gray)'
default: return 'text-red-400' default: return 'var(--color-status-red)'
} }
} }
function statusDot(status: string): string { function statusTextColor(status: string): string {
switch (status) { switch (status) {
case 'Connected': return 'bg-green-400' case 'Connected': return 'var(--color-status-green)'
case 'Connecting': return 'bg-yellow-400 animate-pulse' case 'Connecting': return 'var(--color-status-yellow)'
case 'Disconnected': return 'bg-nb-gray-600' case 'Disconnected': return 'var(--color-text-secondary)'
default: return 'bg-red-400' default: return 'var(--color-status-red)'
} }
} }
@@ -54,9 +57,7 @@ export default function Status() {
useEffect(() => { useEffect(() => {
refresh() refresh()
// Poll every 10 seconds as fallback (push events handle real-time updates)
const id = setInterval(refresh, 10000) const id = setInterval(refresh, 10000)
// Also listen for push events from the tray
const unsub = Events.On('status-changed', (event: { data: StatusInfo[] }) => { const unsub = Events.On('status-changed', (event: { data: StatusInfo[] }) => {
if (event.data[0]) setStatus(event.data[0]) if (event.data[0]) setStatus(event.data[0])
}) })
@@ -97,65 +98,64 @@ export default function Status() {
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Status</h1> <h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Status</h1>
{/* Status card */} {/* Status hero */}
<div className="bg-nb-gray-920 rounded-xl p-6 mb-6 border border-nb-gray-900"> <Card className="mb-6">
<div className="flex items-center gap-3 mb-4"> <div className="px-4 py-5">
<span className={`w-3 h-3 rounded-full ${status ? statusDot(status.status) : 'bg-nb-gray-600'}`} /> <div className="flex items-center gap-3 mb-4">
<span className={`text-xl font-semibold ${status ? statusColor(status.status) : 'text-nb-gray-400'}`}> <span
{status?.status ?? 'Loading…'} className={`w-3 h-3 rounded-full ${status?.status === 'Connecting' ? 'animate-pulse' : ''}`}
</span> 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> </div>
{status && ( {status?.ip && (
<div className="grid grid-cols-2 gap-3 text-sm"> <CardRow label="IP Address">
{status.ip && ( <span className="font-mono text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>{status.ip}</span>
<> </CardRow>
<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> {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>
{/* Action button */} {/* Actions */}
<div className="flex gap-3"> <div className="flex gap-3">
{!isConnected && !isConnecting && ( {!isConnected && !isConnecting && (
<button <Button onClick={handleConnect} disabled={busy}>
onClick={handleConnect} {busy ? 'Connecting\u2026' : 'Connect'}
disabled={busy} </Button>
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) && ( {(isConnected || isConnecting) && (
<button <Button variant="secondary" onClick={handleDisconnect} disabled={busy}>
onClick={handleDisconnect} {busy ? 'Disconnecting\u2026' : 'Disconnect'}
disabled={busy} </Button>
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> </div>
{error && ( {error && (
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm"> <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} {error}
</div> </div>
)} )}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Call } from '@wailsio/runtime' import { Call } from '@wailsio/runtime'
import type { InstallerResult } from '../bindings' 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' type UpdateState = 'idle' | 'triggering' | 'polling' | 'success' | 'failed' | 'timeout'
@@ -10,7 +12,6 @@ export default function Update() {
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null)
// Animate dots when polling
useEffect(() => { useEffect(() => {
if (state !== 'polling') return if (state !== 'polling') return
let count = 0 let count = 0
@@ -40,7 +41,6 @@ export default function Update() {
setState('polling') setState('polling')
// Poll for installer result (up to 15 minutes handled server-side)
try { try {
console.log('[Update] calling services.UpdateService.GetInstallerResult') console.log('[Update] calling services.UpdateService.GetInstallerResult')
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.GetInstallerResult') as InstallerResult const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.GetInstallerResult') as InstallerResult
@@ -51,63 +51,56 @@ export default function Update() {
setErrorMsg(result?.errorMsg ?? 'Update failed') setErrorMsg(result?.errorMsg ?? 'Update failed')
setState('failed') setState('failed')
} }
} catch (e) { } catch {
// If the daemon restarts, the gRPC call may fail — treat as success
setState('success') setState('success')
} }
} }
return ( return (
<div className="max-w-lg mx-auto"> <div className="max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-2">Update</h1> <h1 className="text-xl font-semibold mb-1" style={{ color: 'var(--color-text-primary)' }}>Update</h1>
<p className="text-nb-gray-400 text-sm mb-8"> <p className="text-[13px] mb-8" style={{ color: 'var(--color-text-secondary)' }}>
Trigger an automatic client update managed by the NetBird daemon. Trigger an automatic client update managed by the NetBird daemon.
</p> </p>
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 text-center"> <Card>
{state === 'idle' && ( <div className="px-6 py-8 text-center">
<> {state === 'idle' && (
<p className="text-nb-gray-300 mb-5">Click below to trigger a daemon-managed update.</p> <>
<button <p className="text-[13px] mb-5" style={{ color: 'var(--color-text-secondary)' }}>Click below to trigger a daemon-managed update.</p>
onClick={handleTriggerUpdate} <Button onClick={handleTriggerUpdate}>Trigger Update</Button>
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 rounded-lg font-medium transition-colors" </>
> )}
Trigger Update
</button>
</>
)}
{state === 'triggering' && ( {state === 'triggering' && (
<p className="text-yellow-300 animate-pulse">Triggering update</p> <p className="animate-pulse text-[15px]" style={{ color: 'var(--color-status-yellow)' }}>Triggering update\u2026</p>
)} )}
{state === 'polling' && ( {state === 'polling' && (
<div> <div>
<p className="text-yellow-300 text-lg mb-2">Updating{dots}</p> <p className="text-[17px] mb-2" style={{ color: 'var(--color-status-yellow)' }}>Updating{dots}</p>
<p className="text-nb-gray-400 text-sm">The daemon is installing the update. Please wait.</p> <p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>The daemon is installing the update. Please wait.</p>
</div> </div>
)} )}
{state === 'success' && ( {state === 'success' && (
<div> <div>
<p className="text-green-400 text-lg font-semibold mb-2">Update Successful!</p> <p className="text-[17px] font-semibold mb-2" style={{ color: 'var(--color-status-green)' }}>Update Successful!</p>
<p className="text-nb-gray-300 text-sm">The client has been updated. You may need to restart.</p> <p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>The client has been updated. You may need to restart.</p>
</div> </div>
)} )}
{state === 'failed' && ( {state === 'failed' && (
<div> <div>
<p className="text-red-400 text-lg font-semibold mb-2">Update Failed</p> <p className="text-[17px] font-semibold mb-2" style={{ color: 'var(--color-status-red)' }}>Update Failed</p>
{errorMsg && <p className="text-nb-gray-300 text-sm mb-4">{errorMsg}</p>} {errorMsg && <p className="text-[13px] mb-4" style={{ color: 'var(--color-text-secondary)' }}>{errorMsg}</p>}
<button <Button variant="secondary" onClick={() => { setState('idle'); setErrorMsg('') }}>
onClick={() => { setState('idle'); setErrorMsg('') }} Try Again
className="px-4 py-2 text-sm bg-nb-gray-900 hover:bg-nb-gray-800 rounded-lg transition-colors" </Button>
> </div>
Try Again )}
</button> </div>
</div> </Card>
)}
</div>
</div> </div>
) )
} }

View File

@@ -8,6 +8,7 @@ import (
"flag" "flag"
"os" "os"
"runtime" "runtime"
"time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
@@ -123,6 +124,14 @@ func main() {
evtManager := event.NewManager(*daemonAddr, notify) evtManager := event.NewManager(*daemonAddr, notify)
go evtManager.Start(ctx) 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!")
}()
if err := app.Run(); err != nil { if err := app.Run(); err != nil {
log.Fatalf("app run: %v", err) log.Fatalf("app run: %v", err)
} }

Binary file not shown.