feat: The Other Dude v9.0.1 — full-featured email system

ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
/**
* SimpleApplyBar -- A sticky bottom bar showing the pending change count
* and a "Review & Apply" button for Simple mode category panels.
*/
import { Button } from '@/components/ui/button'
interface SimpleApplyBarProps {
pendingCount: number
isApplying: boolean
onReviewClick: () => void
}
export function SimpleApplyBar({
pendingCount,
isApplying,
onReviewClick,
}: SimpleApplyBarProps) {
if (pendingCount === 0) return null
return (
<div className="flex items-center justify-between pt-4 border-t border-border">
<span className="text-xs text-text-muted">
{pendingCount} pending change{pendingCount !== 1 ? 's' : ''}
</span>
<Button
size="sm"
disabled={pendingCount === 0 || isApplying}
onClick={onReviewClick}
>
{isApplying ? 'Applying...' : 'Review & Apply'}
</Button>
</div>
)
}

View File

@@ -0,0 +1,63 @@
/**
* SimpleConfigSidebar -- Vertical category navigation for Simple mode.
*
* Shows 7 categories with icons, highlighting the active category with a
* left accent border. Includes a "Switch to Standard" shortcut at bottom.
*/
import { Sliders } from 'lucide-react'
import { cn } from '@/lib/utils'
import { SIMPLE_CATEGORIES } from '@/lib/simpleConfigSchema'
interface SimpleConfigSidebarProps {
activeCategory: string
onCategoryChange: (id: string) => void
onSwitchToStandard?: () => void
}
export function SimpleConfigSidebar({
activeCategory,
onCategoryChange,
onSwitchToStandard,
}: SimpleConfigSidebarProps) {
return (
<div className="w-48 flex-shrink-0 flex flex-col min-h-[400px]">
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3 px-3">
Configuration
</p>
<div className="space-y-1">
{SIMPLE_CATEGORIES.map((cat) => {
const Icon = cat.icon
const isActive = activeCategory === cat.id
return (
<button
key={cat.id}
onClick={() => onCategoryChange(cat.id)}
className={cn(
'flex items-center gap-2.5 w-full text-left px-3 py-2 rounded-r-lg text-sm transition-colors',
isActive
? 'bg-accent/10 text-accent border-l-2 border-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-elevated/50 border-l-2 border-transparent',
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{cat.label}</span>
</button>
)
})}
</div>
{onSwitchToStandard && (
<div className="mt-auto pt-4 border-t border-border/50">
<button
onClick={onSwitchToStandard}
className="flex items-center gap-2 w-full text-left px-3 py-2 text-xs text-text-muted hover:text-text-secondary transition-colors"
>
<Sliders className="h-3.5 w-3.5" />
Switch to Standard mode
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,240 @@
/**
* SimpleConfigView -- Mode-aware wrapper that renders either the Simple
* category sidebar + panel content or the Standard vertical sidebar.
*
* Both modes use a vertical sidebar + conditional content panel layout.
* Standard mode groups 31 panels into WinBox-style categories.
* Simple mode shows 7 simplified configuration categories.
*/
import { useState } from 'react'
import type { DeviceResponse } from '@/lib/api'
import { SimpleConfigSidebar } from './SimpleConfigSidebar'
import { StandardConfigSidebar } from './StandardConfigSidebar'
// Simple mode category panel imports
import { InternetSetupPanel } from './categories/InternetSetupPanel'
import { LanDhcpPanel } from './categories/LanDhcpPanel'
import { DnsSimplePanel } from './categories/DnsSimplePanel'
import { WifiSimplePanel } from './categories/WifiSimplePanel'
import { PortForwardingPanel } from './categories/PortForwardingPanel'
import { FirewallBasicsPanel } from './categories/FirewallBasicsPanel'
import { SystemSimplePanel } from './categories/SystemSimplePanel'
// Standard config panel imports
import { HealthTab } from '@/components/monitoring/HealthTab'
import { InterfacesTab } from '@/components/monitoring/InterfacesTab'
import { ConfigTab } from '@/components/config/ConfigTab'
import { InterfacesPanel } from '@/components/config/InterfacesPanel'
import { SwitchPortManager } from '@/components/config/SwitchPortManager'
import { FirewallPanel } from '@/components/config/FirewallPanel'
import { DnsPanel } from '@/components/config/DnsPanel'
import { DhcpPanel } from '@/components/config/DhcpPanel'
import { DhcpClientPanel } from '@/components/config/DhcpClientPanel'
import { WifiPanel } from '@/components/config/WifiPanel'
import { QueuesPanel } from '@/components/config/QueuesPanel'
import { RoutesPanel } from '@/components/config/RoutesPanel'
import { AddressPanel } from '@/components/config/AddressPanel'
import { ArpPanel } from '@/components/config/ArpPanel'
import { PoolPanel } from '@/components/config/PoolPanel'
import { SystemPanel } from '@/components/config/SystemPanel'
import { UsersPanel } from '@/components/config/UsersPanel'
import { ServicesPanel } from '@/components/config/ServicesPanel'
import { ScriptsPanel } from '@/components/config/ScriptsPanel'
import { ManglePanel } from '@/components/config/ManglePanel'
import { AddressListPanel } from '@/components/config/AddressListPanel'
import { ConnTrackPanel } from '@/components/config/ConnTrackPanel'
import { PppPanel } from '@/components/config/PppPanel'
import { IpsecPanel } from '@/components/config/IpsecPanel'
import { NetworkToolsPanel } from '@/components/config/NetworkToolsPanel'
import { BridgePortPanel } from '@/components/config/BridgePortPanel'
import { BridgeVlanPanel } from '@/components/config/BridgeVlanPanel'
import { SnmpPanel } from '@/components/config/SnmpPanel'
import { ClientsTab } from '@/components/network/ClientsTab'
import { VpnTab } from '@/components/network/VpnTab'
import { LogsTab } from '@/components/network/LogsTab'
interface SimpleConfigViewProps {
tenantId: string
deviceId: string
device: DeviceResponse
mode: 'simple' | 'standard'
activeTab: string
onTabChange: (tab: string) => void
onModeChange: (mode: 'simple' | 'standard') => void
/** Render slot for the overview tab content (passed from device detail page) */
overviewContent: React.ReactNode
/** Render slot for the alerts tab content */
alertsContent: React.ReactNode
}
export function SimpleConfigView({
tenantId,
deviceId,
device,
mode,
activeTab,
onTabChange,
onModeChange,
overviewContent,
alertsContent,
}: SimpleConfigViewProps) {
const [activeCategory, setActiveCategory] = useState('internet')
// -------------------------------------------------------------------------
// Standard Mode — WinBox-style vertical sidebar + content panel
// -------------------------------------------------------------------------
if (mode === 'standard') {
return (
<div className="flex gap-6">
<StandardConfigSidebar
activeTab={activeTab}
onTabChange={onTabChange}
onSwitchToSimple={() => onModeChange('simple')}
/>
<div className="flex-1 min-w-0" key={activeTab}>
{activeTab === 'overview' && (
<div className="space-y-4">{overviewContent}</div>
)}
{activeTab === 'health' && (
<HealthTab tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'traffic' && (
<InterfacesTab tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'interfaces' && (
<InterfacesPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'ports' && (
<SwitchPortManager tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'firewall' && (
<FirewallPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'dhcp' && (
<DhcpPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'dhcp-client' && (
<DhcpClientPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'dns' && (
<DnsPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'wifi' && (
<WifiPanel tenantId={tenantId} deviceId={deviceId} active routerosVersion={device.routeros_version} />
)}
{activeTab === 'queues' && (
<QueuesPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'routes' && (
<RoutesPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'addresses' && (
<AddressPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'arp' && (
<ArpPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'pools' && (
<PoolPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'system' && (
<SystemPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'users' && (
<UsersPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'services' && (
<ServicesPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'scripts' && (
<ScriptsPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'mangle' && (
<ManglePanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'addr-lists' && (
<AddressListPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'conntrack' && (
<ConnTrackPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'ppp' && (
<PppPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'ipsec' && (
<IpsecPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'net-tools' && (
<NetworkToolsPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'bridge-ports' && (
<BridgePortPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'bridge-vlans' && (
<BridgeVlanPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'snmp' && (
<SnmpPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'clients' && (
<ClientsTab tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'vpn' && (
<VpnTab tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'logs' && (
<LogsTab tenantId={tenantId} deviceId={deviceId} active />
)}
{activeTab === 'config' && (
<ConfigTab
tenantId={tenantId}
deviceId={deviceId}
deviceHostname={device.hostname}
active
/>
)}
{activeTab === 'alerts' && alertsContent}
</div>
</div>
)
}
// -------------------------------------------------------------------------
// Simple Mode — vertical sidebar + category panels
// -------------------------------------------------------------------------
return (
<div className="flex gap-6">
<SimpleConfigSidebar
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
onSwitchToStandard={() => onModeChange('standard')}
/>
<div className="flex-1 min-w-0 max-w-2xl" key={activeCategory}>
{activeCategory === 'internet' && (
<InternetSetupPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeCategory === 'lan' && (
<LanDhcpPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeCategory === 'wifi' && (
<WifiSimplePanel tenantId={tenantId} deviceId={deviceId} active routerosVersion={device.routeros_version} />
)}
{activeCategory === 'port-forwarding' && (
<PortForwardingPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeCategory === 'firewall' && (
<FirewallBasicsPanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeCategory === 'dns' && (
<DnsSimplePanel tenantId={tenantId} deviceId={deviceId} active />
)}
{activeCategory === 'system' && (
<SystemSimplePanel tenantId={tenantId} deviceId={deviceId} active />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,132 @@
/**
* SimpleFormField -- Renders the appropriate input component based on a SimpleFieldDef.
*
* Supports: text, ip, cidr, number, boolean, select, password field types.
* Includes label, required indicator, help text, and error display.
*/
import { useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { SimpleFieldDef } from '@/lib/simpleConfigSchema'
interface SimpleFormFieldProps {
field: SimpleFieldDef
value: string
onChange: (value: string) => void
error?: string
}
export function SimpleFormField({ field, value, onChange, error }: SimpleFormFieldProps) {
const [showPassword, setShowPassword] = useState(false)
const fieldId = `simple-field-${field.key}`
return (
<div className="space-y-1.5">
{field.type !== 'boolean' && (
<Label htmlFor={fieldId} className="text-sm text-text-primary">
{field.label}
{field.required && <span className="text-error ml-0.5">*</span>}
</Label>
)}
{/* Text / IP / CIDR */}
{(field.type === 'text' || field.type === 'ip' || field.type === 'cidr') && (
<Input
id={fieldId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="h-8 text-sm"
/>
)}
{/* Number */}
{field.type === 'number' && (
<Input
id={fieldId}
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="h-8 text-sm"
/>
)}
{/* Boolean */}
{field.type === 'boolean' && (
<div className="flex items-center gap-2">
<Checkbox
id={fieldId}
checked={value === 'true' || value === 'yes'}
onCheckedChange={(checked) =>
onChange(checked ? 'true' : 'false')
}
/>
<Label htmlFor={fieldId} className="text-sm text-text-primary cursor-pointer">
{field.label}
</Label>
</div>
)}
{/* Select */}
{field.type === 'select' && (
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={fieldId} className="h-8 text-sm">
<SelectValue placeholder={field.placeholder ?? 'Select...'} />
</SelectTrigger>
<SelectContent>
{field.options?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Password */}
{field.type === 'password' && (
<div className="relative">
<Input
id={fieldId}
type={showPassword ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="h-8 text-sm pr-9"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-0 top-0 h-8 w-8 p-0"
>
{showPassword ? (
<EyeOff className="h-3.5 w-3.5 text-text-muted" />
) : (
<Eye className="h-3.5 w-3.5 text-text-muted" />
)}
</Button>
</div>
)}
{field.help && (
<p className="text-xs text-text-muted">{field.help}</p>
)}
{error && <p className="text-xs text-error">{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,35 @@
/**
* SimpleFormSection -- A card component wrapping a group of form fields with
* an icon, title, and optional description.
*/
import type { LucideIcon } from 'lucide-react'
interface SimpleFormSectionProps {
icon: LucideIcon
title: string
description?: string
children: React.ReactNode
}
export function SimpleFormSection({
icon: Icon,
title,
description,
children,
}: SimpleFormSectionProps) {
return (
<div className="rounded-lg border border-border bg-surface p-4 space-y-4">
<div className="flex items-center gap-2.5">
<Icon className="h-4.5 w-4.5 text-accent flex-shrink-0" />
<div>
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
{description && (
<p className="text-xs text-text-muted mt-0.5">{description}</p>
)}
</div>
</div>
<div className="space-y-3">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,44 @@
/**
* SimpleModeToggle -- Compact pill-shaped toggle between Simple and Standard
* configuration modes. Matches the SafetyToggle visual style.
*/
import { LayoutGrid, Sliders } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface SimpleModeToggleProps {
mode: 'simple' | 'standard'
onModeChange: (mode: 'simple' | 'standard') => void
}
export function SimpleModeToggle({ mode, onModeChange }: SimpleModeToggleProps) {
return (
<div className="flex items-center gap-1 rounded-lg border border-border bg-elevated/50 p-1">
<Button
variant="ghost"
size="sm"
onClick={() => onModeChange('simple')}
className={cn(
'gap-1.5 h-7 px-2.5 text-xs',
mode === 'simple' && 'bg-accent/20 text-accent',
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
Simple
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onModeChange('standard')}
className={cn(
'gap-1.5 h-7 px-2.5 text-xs',
mode === 'standard' && 'bg-accent/20 text-accent',
)}
>
<Sliders className="h-3.5 w-3.5" />
Standard
</Button>
</div>
)
}

View File

@@ -0,0 +1,30 @@
/**
* SimpleStatusBanner -- A horizontal bar showing key current-config values
* at a glance at the top of each Simple mode category panel.
*/
interface SimpleStatusBannerProps {
items: { label: string; value: string }[]
isLoading?: boolean
}
export function SimpleStatusBanner({ items, isLoading }: SimpleStatusBannerProps) {
return (
<div className="rounded-lg border border-border bg-elevated/50 px-4 py-3">
<div className="flex flex-wrap gap-x-6 gap-y-2">
{items.map((item, i) => (
<div key={i} className="flex flex-col">
<span className="text-xs text-text-muted">{item.label}</span>
{isLoading ? (
<div className="h-5 w-24 mt-0.5 rounded bg-elevated animate-shimmer" />
) : (
<span className="text-sm font-medium text-text-primary">
{item.value || '\u2014'}
</span>
)}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,182 @@
/**
* StandardConfigSidebar -- WinBox-style vertical navigation for Standard mode.
*
* Groups 31 tabs into 10 categories mirroring WinBox's tree menu.
* Uses the same accent-left-border styling as SimpleConfigSidebar.
*/
import {
Activity,
Network,
Globe,
Shield,
Wifi,
Gauge,
Lock,
Settings,
Wrench,
FolderCog,
Sliders,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SidebarGroup {
label: string
icon: LucideIcon
items: { id: string; label: string }[]
}
const STANDARD_GROUPS: SidebarGroup[] = [
{
label: 'Monitor',
icon: Activity,
items: [
{ id: 'overview', label: 'Overview' },
{ id: 'health', label: 'Health' },
{ id: 'traffic', label: 'Traffic' },
],
},
{
label: 'Interfaces',
icon: Network,
items: [
{ id: 'interfaces', label: 'Interfaces' },
{ id: 'ports', label: 'Ports' },
{ id: 'bridge-ports', label: 'Bridge Ports' },
{ id: 'bridge-vlans', label: 'VLANs' },
],
},
{
label: 'IP',
icon: Globe,
items: [
{ id: 'addresses', label: 'Addresses' },
{ id: 'routes', label: 'Routes' },
{ id: 'arp', label: 'ARP' },
{ id: 'pools', label: 'Pools' },
{ id: 'dns', label: 'DNS' },
{ id: 'dhcp', label: 'DHCP Server' },
{ id: 'dhcp-client', label: 'DHCP Client' },
],
},
{
label: 'Firewall',
icon: Shield,
items: [
{ id: 'firewall', label: 'Firewall' },
{ id: 'mangle', label: 'Mangle' },
{ id: 'addr-lists', label: 'Addr Lists' },
{ id: 'conntrack', label: 'ConnTrack' },
],
},
{
label: 'WiFi',
icon: Wifi,
items: [{ id: 'wifi', label: 'WiFi' }],
},
{
label: 'Queues',
icon: Gauge,
items: [{ id: 'queues', label: 'Queues' }],
},
{
label: 'VPN',
icon: Lock,
items: [
{ id: 'ppp', label: 'PPP' },
{ id: 'ipsec', label: 'IPsec' },
{ id: 'vpn', label: 'VPN' },
],
},
{
label: 'System',
icon: Settings,
items: [
{ id: 'system', label: 'System' },
{ id: 'users', label: 'Users' },
{ id: 'services', label: 'Services' },
{ id: 'scripts', label: 'Scripts' },
{ id: 'snmp', label: 'SNMP' },
],
},
{
label: 'Tools',
icon: Wrench,
items: [
{ id: 'net-tools', label: 'Tools' },
{ id: 'clients', label: 'Clients' },
{ id: 'logs', label: 'Logs' },
],
},
{
label: 'Manage',
icon: FolderCog,
items: [
{ id: 'config', label: 'Config' },
{ id: 'alerts', label: 'Alerts' },
],
},
]
interface StandardConfigSidebarProps {
activeTab: string
onTabChange: (tab: string) => void
onSwitchToSimple?: () => void
}
export function StandardConfigSidebar({
activeTab,
onTabChange,
onSwitchToSimple,
}: StandardConfigSidebarProps) {
return (
<div className="w-48 flex-shrink-0 flex flex-col min-h-[400px]">
<nav className="space-y-3 overflow-y-auto flex-1">
{STANDARD_GROUPS.map((group) => {
const GroupIcon = group.icon
return (
<div key={group.label}>
<p className="flex items-center gap-1.5 text-xs font-medium text-text-muted uppercase tracking-wider mb-1 px-3">
<GroupIcon className="h-3 w-3" />
{group.label}
</p>
<div className="space-y-0.5">
{group.items.map((item) => {
const isActive = activeTab === item.id
return (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
data-testid={`tab-${item.id}`}
className={cn(
'flex items-center w-full text-left pl-7 pr-3 py-1.5 rounded-r-lg text-sm transition-colors',
isActive
? 'bg-accent/10 text-accent border-l-2 border-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-elevated/50 border-l-2 border-transparent',
)}
>
<span className="truncate">{item.label}</span>
</button>
)
})}
</div>
</div>
)
})}
</nav>
{onSwitchToSimple && (
<div className="mt-auto pt-4 border-t border-border/50">
<button
onClick={onSwitchToSimple}
className="flex items-center gap-2 w-full text-left px-3 py-2 text-xs text-text-muted hover:text-text-secondary transition-colors"
>
<Sliders className="h-3.5 w-3.5" />
Switch to Simple mode
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,299 @@
/**
* DnsSimplePanel -- Simplified DNS configuration for Simple mode.
*
* Manages upstream servers, allow-remote-requests toggle, and static DNS entries.
* Simpler than the Standard DnsPanel: no TTL, no MX/TXT types, no advanced settings.
*/
import { useState, useEffect } from 'react'
import { Server, Globe, Plus, Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
import { SimpleFormField } from '../SimpleFormField'
import { SimpleFormSection } from '../SimpleFormSection'
import { SimpleStatusBanner } from '../SimpleStatusBanner'
import { SimpleApplyBar } from '../SimpleApplyBar'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
const DNS_TYPES = ['A', 'AAAA', 'CNAME'] as const
interface StaticFormState {
name: string
address: string
type: string
}
const EMPTY_STATIC: StaticFormState = { name: '', address: '', type: 'A' }
export function DnsSimplePanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const dnsSettings = useConfigBrowse(tenantId, deviceId, '/ip/dns', { enabled: active })
const staticEntries = useConfigBrowse(tenantId, deviceId, '/ip/dns/static', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'simple-dns')
const [previewOpen, setPreviewOpen] = useState(false)
// DNS settings form
const settings = dnsSettings.entries[0]
const [editedServers, setEditedServers] = useState<string | null>(null)
const [editedRemote, setEditedRemote] = useState<string | null>(null)
const [editedCacheSize, setEditedCacheSize] = useState<string | null>(null)
const currentServers = editedServers ?? settings?.servers ?? ''
const currentRemote = editedRemote ?? settings?.['allow-remote-requests'] ?? 'false'
const currentCacheSize = editedCacheSize ?? settings?.['cache-size'] ?? ''
// Static entry dialog
const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [staticForm, setStaticForm] = useState<StaticFormState>(EMPTY_STATIC)
const isLoading = dnsSettings.isLoading || staticEntries.isLoading
const stageResolverChanges = () => {
const props: Record<string, string> = {}
if (editedServers !== null) props.servers = editedServers
if (editedRemote !== null) props['allow-remote-requests'] = editedRemote
if (editedCacheSize !== null) props['cache-size'] = editedCacheSize
if (Object.keys(props).length > 0) {
panel.addChange({
operation: 'set',
path: '/ip/dns',
entryId: settings?.['.id'],
properties: props,
description: `Update DNS settings${editedServers !== null ? ` (servers: ${editedServers})` : ''}`,
})
setEditedServers(null)
setEditedRemote(null)
setEditedCacheSize(null)
}
}
const openAddDialog = () => {
setEditingId(null)
setStaticForm(EMPTY_STATIC)
setDialogOpen(true)
}
const openEditDialog = (entry: Record<string, string>) => {
setEditingId(entry['.id'])
setStaticForm({
name: entry.name ?? '',
address: entry.address ?? '',
type: entry.type ?? 'A',
})
setDialogOpen(true)
}
const handleStaticSave = () => {
const props: Record<string, string> = {
name: staticForm.name,
address: staticForm.address,
type: staticForm.type,
}
if (editingId) {
panel.addChange({
operation: 'set',
path: '/ip/dns/static',
entryId: editingId,
properties: props,
description: `Update DNS entry: ${staticForm.name} -> ${staticForm.address}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/ip/dns/static',
properties: props,
description: `Add DNS entry: ${staticForm.name} -> ${staticForm.address}`,
})
}
setDialogOpen(false)
setStaticForm(EMPTY_STATIC)
setEditingId(null)
}
const handleStaticDelete = (entry: Record<string, string>) => {
panel.addChange({
operation: 'remove',
path: '/ip/dns/static',
entryId: entry['.id'],
properties: {},
description: `Delete DNS entry: ${entry.name}`,
})
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-text-muted">
Loading DNS configuration...
</div>
)
}
return (
<div className="space-y-6">
<SimpleStatusBanner
items={[
{ label: 'DNS Servers', value: settings?.servers || 'Not configured' },
{ label: 'Remote Requests', value: settings?.['allow-remote-requests'] === 'true' ? 'Allowed' : 'Blocked' },
{ label: 'Static Entries', value: String(staticEntries.entries.length) },
]}
isLoading={isLoading}
/>
<SimpleFormSection icon={Server} title="DNS Servers" description="Configure upstream DNS resolvers">
<SimpleFormField
field={{ key: 'servers', label: 'Upstream Servers', type: 'text', required: true, placeholder: '8.8.8.8,8.8.4.4', help: 'Comma-separated list of DNS server IPs used for name resolution' }}
value={currentServers}
onChange={setEditedServers}
/>
<SimpleFormField
field={{ key: 'allow-remote-requests', label: 'Allow Remote Requests', type: 'boolean', help: 'Allow devices on your network to use this router as their DNS server' }}
value={currentRemote}
onChange={setEditedRemote}
/>
<SimpleFormField
field={{ key: 'cache-size', label: 'Cache Size (KiB)', type: 'number', placeholder: '2048', help: 'DNS cache size in KiB' }}
value={currentCacheSize}
onChange={setEditedCacheSize}
/>
<div className="pt-2">
<Button size="sm" variant="outline" onClick={stageResolverChanges}>
Stage Changes
</Button>
</div>
</SimpleFormSection>
<SimpleFormSection icon={Globe} title="Static DNS Entries" description="Local name resolution overrides">
{staticEntries.entries.length === 0 ? (
<p className="text-xs text-text-muted">No static DNS entries configured</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="text-left px-3 py-2 font-medium text-text-muted">Name</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Address</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Type</th>
<th className="text-right px-3 py-2 font-medium text-text-muted">Actions</th>
</tr>
</thead>
<tbody>
{staticEntries.entries.map((entry) => (
<tr key={entry['.id']} className="border-b border-border/30 last:border-0">
<td className="px-3 py-1.5 text-text-primary">{entry.name}</td>
<td className="px-3 py-1.5 font-mono text-text-secondary">{entry.address}</td>
<td className="px-3 py-1.5 text-text-muted">{entry.type ?? 'A'}</td>
<td className="px-3 py-1.5 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => openEditDialog(entry)}>
<Pencil className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-error" onClick={() => handleStaticDelete(entry)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button size="sm" variant="outline" onClick={openAddDialog} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add Entry
</Button>
</SimpleFormSection>
<SimpleApplyBar
pendingCount={panel.pendingChanges.length}
isApplying={panel.isApplying}
onReviewClick={() => setPreviewOpen(true)}
/>
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }}
isApplying={panel.isApplying}
/>
{/* Static entry add/edit dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{editingId ? 'Edit DNS Entry' : 'Add DNS Entry'}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-sm">Name <span className="text-error">*</span></Label>
<Input
value={staticForm.name}
onChange={(e) => setStaticForm((f) => ({ ...f, name: e.target.value }))}
placeholder="myserver.local"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-sm">Address <span className="text-error">*</span></Label>
<Input
value={staticForm.address}
onChange={(e) => setStaticForm((f) => ({ ...f, address: e.target.value }))}
placeholder="192.168.88.100"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-sm">Type</Label>
<Select value={staticForm.type} onValueChange={(v) => setStaticForm((f) => ({ ...f, type: v }))}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DNS_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleStaticSave}
disabled={!staticForm.name || !staticForm.address}
>
{editingId ? 'Save' : 'Add'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,659 @@
/**
* FirewallBasicsPanel -- Simple mode firewall configuration.
*
* Shows input and forward chain rules in separate sections with simplified views.
* Also displays address lists grouped by list name.
* Allows adding basic allow/block rules without exposing full firewall complexity.
*/
import { useState } from 'react'
import { Shield, Network, List, Plus, Pencil, Trash2, Power, PowerOff, ChevronDown, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
import { SimpleFormSection } from '../SimpleFormSection'
import { SimpleStatusBanner } from '../SimpleStatusBanner'
import { SimpleApplyBar } from '../SimpleApplyBar'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
import { cn } from '@/lib/utils'
// ---- Types ----
interface RuleForm {
chain: string
action: string
protocol: string
'dst-port': string
'src-address': string
comment: string
disabled: string
}
interface AddressListForm {
list: string
address: string
comment: string
}
const EMPTY_RULE: RuleForm = {
chain: 'input',
action: 'accept',
protocol: 'any',
'dst-port': '',
'src-address': '',
comment: '',
disabled: 'false',
}
const EMPTY_ADDR: AddressListForm = {
list: '',
address: '',
comment: '',
}
const ACTION_OPTIONS = [
{ value: 'accept', label: 'Accept' },
{ value: 'drop', label: 'Drop' },
{ value: 'reject', label: 'Reject' },
]
const PROTOCOL_OPTIONS = [
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
{ value: 'icmp', label: 'ICMP' },
{ value: 'any', label: 'Any' },
]
const ACTION_COLORS: Record<string, string> = {
accept: 'bg-success/20 text-success',
drop: 'bg-error/20 text-error',
reject: 'bg-warning/20 text-warning',
}
// ---- Component ----
export function FirewallBasicsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const filterRules = useConfigBrowse(tenantId, deviceId, '/ip/firewall/filter', { enabled: active })
const addressLists = useConfigBrowse(tenantId, deviceId, '/ip/firewall/address-list', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'simple-firewall')
const [previewOpen, setPreviewOpen] = useState(false)
// Rule dialog state
const [ruleDialogOpen, setRuleDialogOpen] = useState(false)
const [editingRuleId, setEditingRuleId] = useState<string | null>(null)
const [ruleForm, setRuleForm] = useState<RuleForm>(EMPTY_RULE)
// Address list dialog state
const [addrDialogOpen, setAddrDialogOpen] = useState(false)
const [addrForm, setAddrForm] = useState<AddressListForm>(EMPTY_ADDR)
// Address delete confirmation state
const [confirmAddrDeleteOpen, setConfirmAddrDeleteOpen] = useState(false)
const [deletingAddr, setDeletingAddr] = useState<Record<string, string> | null>(null)
// Collapsed address list groups
const [collapsedLists, setCollapsedLists] = useState<Set<string>>(new Set())
// Filter rules by chain
const inputRules = filterRules.entries.filter((e) => e.chain === 'input')
const forwardRules = filterRules.entries.filter((e) => e.chain === 'forward')
// Group address list entries by list name
const addressListGroups: Record<string, Array<Record<string, string>>> = {}
addressLists.entries.forEach((entry) => {
const listName = entry.list ?? 'unknown'
if (!addressListGroups[listName]) addressListGroups[listName] = []
addressListGroups[listName].push(entry)
})
const uniqueListCount = Object.keys(addressListGroups).length
const isLoading = filterRules.isLoading || addressLists.isLoading
// ---- Rule dialog handlers ----
const openAddRuleDialog = (chain: string) => {
setEditingRuleId(null)
setRuleForm({ ...EMPTY_RULE, chain })
setRuleDialogOpen(true)
}
const openEditRuleDialog = (entry: Record<string, string>) => {
setEditingRuleId(entry['.id'])
setRuleForm({
chain: entry.chain ?? 'input',
action: entry.action ?? 'accept',
protocol: entry.protocol || 'any',
'dst-port': entry['dst-port'] ?? '',
'src-address': entry['src-address'] ?? '',
comment: entry.comment ?? '',
disabled: entry.disabled ?? 'false',
})
setRuleDialogOpen(true)
}
const handleRuleSave = () => {
const props: Record<string, string> = {
chain: ruleForm.chain,
action: ruleForm.action,
}
if (ruleForm.protocol && ruleForm.protocol !== 'any') props.protocol = ruleForm.protocol
if (ruleForm['dst-port'] && (ruleForm.protocol === 'tcp' || ruleForm.protocol === 'udp')) {
props['dst-port'] = ruleForm['dst-port']
}
if (ruleForm['src-address']) props['src-address'] = ruleForm['src-address']
if (ruleForm.comment) props.comment = ruleForm.comment
if (ruleForm.disabled === 'true') props.disabled = 'true'
const chainLabel = ruleForm.chain === 'input' ? 'Input' : 'Forward'
if (editingRuleId) {
panel.addChange({
operation: 'set',
path: '/ip/firewall/filter',
entryId: editingRuleId,
properties: props,
description: `Update ${chainLabel} rule: ${ruleForm.action} ${ruleForm.protocol === 'any' ? 'any' : ruleForm.protocol}${ruleForm['dst-port'] ? `:${ruleForm['dst-port']}` : ''}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/ip/firewall/filter',
properties: props,
description: `Add ${chainLabel} rule: ${ruleForm.action} ${ruleForm.protocol === 'any' ? 'any' : ruleForm.protocol}${ruleForm['dst-port'] ? `:${ruleForm['dst-port']}` : ''}`,
})
}
setRuleDialogOpen(false)
setRuleForm(EMPTY_RULE)
setEditingRuleId(null)
}
const handleRuleDelete = (entry: Record<string, string>) => {
panel.addChange({
operation: 'remove',
path: '/ip/firewall/filter',
entryId: entry['.id'],
properties: {},
description: `Delete firewall rule: ${entry.comment || `${entry.action} ${entry.protocol || 'any'}`}`,
})
}
const handleRuleToggle = (entry: Record<string, string>) => {
const newDisabled = entry.disabled === 'true' ? 'false' : 'true'
panel.addChange({
operation: 'set',
path: '/ip/firewall/filter',
entryId: entry['.id'],
properties: { disabled: newDisabled },
description: `${newDisabled === 'true' ? 'Disable' : 'Enable'} firewall rule: ${entry.comment || entry['.id']}`,
})
}
// ---- Address list handlers ----
const openAddrDialog = () => {
setAddrForm(EMPTY_ADDR)
setAddrDialogOpen(true)
}
const handleAddrSave = () => {
panel.addChange({
operation: 'add',
path: '/ip/firewall/address-list',
properties: {
list: addrForm.list,
address: addrForm.address,
...(addrForm.comment ? { comment: addrForm.comment } : {}),
},
description: `Add address ${addrForm.address} to list "${addrForm.list}"`,
})
setAddrDialogOpen(false)
setAddrForm(EMPTY_ADDR)
}
const handleAddrDelete = (entry: Record<string, string>) => {
setDeletingAddr(entry)
setConfirmAddrDeleteOpen(true)
}
const confirmAddrDelete = () => {
if (!deletingAddr) return
panel.addChange({
operation: 'remove',
path: '/ip/firewall/address-list',
entryId: deletingAddr['.id'],
properties: {},
description: `Remove ${deletingAddr.address} from list "${deletingAddr.list}"`,
})
setConfirmAddrDeleteOpen(false)
setDeletingAddr(null)
}
const toggleListCollapse = (listName: string) => {
setCollapsedLists((prev) => {
const next = new Set(prev)
if (next.has(listName)) next.delete(listName)
else next.add(listName)
return next
})
}
// ---- Loading ----
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-text-muted">
Loading firewall configuration...
</div>
)
}
// ---- Render ----
return (
<div className="space-y-6">
<SimpleStatusBanner
items={[
{ label: 'Input Rules', value: String(inputRules.length) },
{ label: 'Forward Rules', value: String(forwardRules.length) },
{ label: 'Address Lists', value: String(uniqueListCount) },
]}
isLoading={isLoading}
/>
{/* Input Chain */}
<SimpleFormSection icon={Shield} title="Incoming Traffic" description="Rules that control traffic destined to this router itself">
<RulesTable
rules={inputRules}
onEdit={openEditRuleDialog}
onDelete={handleRuleDelete}
onToggle={handleRuleToggle}
/>
<Button size="sm" variant="outline" onClick={() => openAddRuleDialog('input')} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add Input Rule
</Button>
</SimpleFormSection>
{/* Forward Chain */}
<SimpleFormSection icon={Network} title="Forwarded Traffic" description="Rules that control traffic passing through this router">
<RulesTable
rules={forwardRules}
onEdit={openEditRuleDialog}
onDelete={handleRuleDelete}
onToggle={handleRuleToggle}
/>
<Button size="sm" variant="outline" onClick={() => openAddRuleDialog('forward')} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add Forward Rule
</Button>
</SimpleFormSection>
{/* Address Lists */}
<SimpleFormSection icon={List} title="Address Lists" description="Named groups of IP addresses used by firewall rules">
{uniqueListCount === 0 ? (
<p className="text-xs text-text-muted">No address lists configured</p>
) : (
<div className="space-y-2">
{Object.entries(addressListGroups).map(([listName, entries]) => {
const isCollapsed = collapsedLists.has(listName)
return (
<div key={listName} className="rounded-lg border border-border/50 overflow-hidden">
<button
className="flex items-center gap-2 w-full px-3 py-2 bg-elevated/20 hover:bg-elevated/40 transition-colors text-left"
onClick={() => toggleListCollapse(listName)}
>
{isCollapsed ? (
<ChevronRight className="h-3.5 w-3.5 text-text-muted" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-text-muted" />
)}
<span className="text-xs font-medium text-text-primary">{listName}</span>
<span className="text-xs bg-elevated px-1.5 py-0.5 rounded text-text-muted">
{entries.length}
</span>
</button>
{!isCollapsed && (
<div className="px-3 py-2 flex flex-wrap gap-1.5">
{entries.map((entry) => (
<span
key={entry['.id']}
className="inline-flex items-center gap-1 rounded bg-elevated/50 px-2 py-0.5 text-xs font-mono text-text-secondary"
>
{entry.address}
<button
className="text-text-muted hover:text-error transition-colors"
onClick={() => handleAddrDelete(entry)}
title="Remove from list"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
</span>
))}
</div>
)}
</div>
)
})}
</div>
)}
<Button size="sm" variant="outline" onClick={openAddrDialog} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add to List
</Button>
</SimpleFormSection>
<SimpleApplyBar
pendingCount={panel.pendingChanges.length}
isApplying={panel.isApplying}
onReviewClick={() => setPreviewOpen(true)}
/>
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }}
isApplying={panel.isApplying}
/>
{/* Add/Edit Rule Dialog */}
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{editingRuleId ? 'Edit Firewall Rule' : 'Add Firewall Rule'}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-sm">Chain <span className="text-error">*</span></Label>
<Select value={ruleForm.chain} onValueChange={(v) => setRuleForm((f) => ({ ...f, chain: v }))}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="input">Input</SelectItem>
<SelectItem value="forward">Forward</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-sm">Action <span className="text-error">*</span></Label>
<Select value={ruleForm.action} onValueChange={(v) => setRuleForm((f) => ({ ...f, action: v }))}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-sm">Protocol <span className="text-error">*</span></Label>
<Select value={ruleForm.protocol} onValueChange={(v) => setRuleForm((f) => ({ ...f, protocol: v }))}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROTOCOL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{(ruleForm.protocol === 'tcp' || ruleForm.protocol === 'udp') && (
<div className="space-y-1">
<Label className="text-sm">Destination Port</Label>
<Input
type="number"
min={1}
max={65535}
value={ruleForm['dst-port']}
onChange={(e) => setRuleForm((f) => ({ ...f, 'dst-port': e.target.value }))}
placeholder="e.g., 22, 80, 443"
className="h-8 text-sm"
/>
</div>
)}
<div className="space-y-1">
<Label className="text-sm">Source Address</Label>
<Input
value={ruleForm['src-address']}
onChange={(e) => setRuleForm((f) => ({ ...f, 'src-address': e.target.value }))}
placeholder="e.g., 192.168.88.0/24"
className="h-8 text-sm"
/>
<p className="text-xs text-text-muted">Leave blank to match any source</p>
</div>
<div className="space-y-1">
<Label className="text-sm">Comment</Label>
<Input
value={ruleForm.comment}
onChange={(e) => setRuleForm((f) => ({ ...f, comment: e.target.value }))}
placeholder="e.g., Allow SSH from LAN"
className="h-8 text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setRuleDialogOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleRuleSave}>
{editingRuleId ? 'Save' : 'Add'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Address List Entry Dialog */}
<Dialog open={addrDialogOpen} onOpenChange={setAddrDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Add to Address List</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-sm">List Name <span className="text-error">*</span></Label>
<Input
value={addrForm.list}
onChange={(e) => setAddrForm((f) => ({ ...f, list: e.target.value }))}
placeholder="e.g., blocklist, trusted"
className="h-8 text-sm"
list="existing-lists"
/>
{uniqueListCount > 0 && (
<datalist id="existing-lists">
{Object.keys(addressListGroups).map((name) => (
<option key={name} value={name} />
))}
</datalist>
)}
</div>
<div className="space-y-1">
<Label className="text-sm">Address <span className="text-error">*</span></Label>
<Input
value={addrForm.address}
onChange={(e) => setAddrForm((f) => ({ ...f, address: e.target.value }))}
placeholder="192.168.88.100 or 10.0.0.0/8"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-sm">Comment</Label>
<Input
value={addrForm.comment}
onChange={(e) => setAddrForm((f) => ({ ...f, comment: e.target.value }))}
placeholder="Optional description"
className="h-8 text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setAddrDialogOpen(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleAddrSave}
disabled={!addrForm.list || !addrForm.address}
>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Address Delete Confirmation Dialog */}
<Dialog open={confirmAddrDeleteOpen} onOpenChange={(o) => { if (!o) { setConfirmAddrDeleteOpen(false); setDeletingAddr(null) } }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Remove Address?</DialogTitle>
</DialogHeader>
<p className="text-sm text-text-secondary">
This will stage the removal of this address from the firewall list. The change takes effect when you apply.
</p>
{deletingAddr && (
<div className="space-y-1.5 bg-elevated/50 rounded-lg p-3">
<div className="flex gap-2 text-xs">
<span className="text-text-muted w-14 shrink-0">List</span>
<span className="font-medium text-text-primary">{deletingAddr.list}</span>
</div>
<div className="flex gap-2 text-xs">
<span className="text-text-muted w-14 shrink-0">Address</span>
<span className="font-mono text-text-primary">{deletingAddr.address}</span>
</div>
{deletingAddr.comment && (
<div className="flex gap-2 text-xs">
<span className="text-text-muted w-14 shrink-0">Comment</span>
<span className="text-text-secondary italic">{deletingAddr.comment}</span>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => { setConfirmAddrDeleteOpen(false); setDeletingAddr(null) }}>
Cancel
</Button>
<Button size="sm" variant="destructive" onClick={confirmAddrDelete}>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ---- Rules Table sub-component ----
function RulesTable({
rules,
onEdit,
onDelete,
onToggle,
}: {
rules: Array<Record<string, string>>
onEdit: (entry: Record<string, string>) => void
onDelete: (entry: Record<string, string>) => void
onToggle: (entry: Record<string, string>) => void
}) {
if (rules.length === 0) {
return <p className="text-xs text-text-muted">No rules in this chain</p>
}
return (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="text-left px-3 py-2 font-medium text-text-muted w-8">#</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Action</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Protocol</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Port</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Source</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Comment</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Status</th>
<th className="text-right px-3 py-2 font-medium text-text-muted">Actions</th>
</tr>
</thead>
<tbody>
{rules.map((entry, idx) => (
<tr
key={entry['.id']}
className={cn(
'border-b border-border/30 last:border-0',
entry.disabled === 'true' && 'opacity-50',
)}
>
<td className="px-3 py-1.5 text-text-muted">{idx + 1}</td>
<td className="px-3 py-1.5">
<span className={cn(
'inline-block px-1.5 py-0.5 rounded text-[10px] font-medium uppercase',
ACTION_COLORS[entry.action] ?? 'bg-elevated text-text-muted',
)}>
{entry.action}
</span>
</td>
<td className="px-3 py-1.5 text-text-secondary uppercase">{entry.protocol || 'any'}</td>
<td className="px-3 py-1.5 font-mono text-text-secondary">{entry['dst-port'] || 'any'}</td>
<td className="px-3 py-1.5 font-mono text-text-secondary">{entry['src-address'] || 'any'}</td>
<td className="px-3 py-1.5 text-text-muted truncate max-w-[120px]">{entry.comment || '\u2014'}</td>
<td className="px-3 py-1.5">
{entry.disabled === 'true' ? (
<span className="text-text-muted">Off</span>
) : (
<span className="text-success">On</span>
)}
</td>
<td className="px-3 py-1.5 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => onToggle(entry)}
title={entry.disabled === 'true' ? 'Enable' : 'Disable'}
>
{entry.disabled === 'true' ? (
<Power className="h-3 w-3" />
) : (
<PowerOff className="h-3 w-3" />
)}
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => onEdit(entry)}>
<Pencil className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-error" onClick={() => onDelete(entry)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,325 @@
/**
* InternetSetupPanel -- Simple mode Internet/WAN configuration.
*
* Detects current WAN type (DHCP/PPPoE/Static) from live device data
* and provides appropriate form fields for editing the connection.
*/
import { useState, useEffect } from 'react'
import { Globe } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
import { SimpleFormField } from '../SimpleFormField'
import { SimpleFormSection } from '../SimpleFormSection'
import { SimpleStatusBanner } from '../SimpleStatusBanner'
import { SimpleApplyBar } from '../SimpleApplyBar'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
import { cn } from '@/lib/utils'
type WanType = 'dhcp' | 'pppoe' | 'static'
const WAN_OPTIONS: { value: WanType; label: string }[] = [
{ value: 'dhcp', label: 'DHCP' },
{ value: 'pppoe', label: 'PPPoE' },
{ value: 'static', label: 'Static IP' },
]
export function InternetSetupPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const dhcpClient = useConfigBrowse(tenantId, deviceId, '/ip/dhcp-client', { enabled: active })
const pppoeClient = useConfigBrowse(tenantId, deviceId, '/interface/pppoe-client', { enabled: active })
const interfaces = useConfigBrowse(tenantId, deviceId, '/interface', { enabled: active })
const routes = useConfigBrowse(tenantId, deviceId, '/ip/route', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'simple-internet')
const [previewOpen, setPreviewOpen] = useState(false)
// Detect WAN type
const detectedWanType: WanType =
pppoeClient.entries.length > 0 ? 'pppoe' :
dhcpClient.entries.length > 0 ? 'dhcp' : 'static'
const [wanType, setWanType] = useState<WanType>(detectedWanType)
// DHCP form state
const dhcpEntry = dhcpClient.entries[0]
const [dhcpForm, setDhcpForm] = useState({
interface: '',
'use-peer-dns': 'true',
'use-peer-ntp': 'true',
'add-default-route': 'true',
})
// PPPoE form state
const pppoeEntry = pppoeClient.entries[0]
const [pppoeForm, setPppoeForm] = useState({
interface: '',
user: '',
password: '',
'service-name': '',
'use-peer-dns': 'true',
})
// Static form state
const [staticForm, setStaticForm] = useState({
address: '',
interface: '',
gateway: '',
'dns-servers': '',
})
// Sync form state from browse data
useEffect(() => {
if (dhcpEntry) {
setDhcpForm({
interface: dhcpEntry.interface ?? '',
'use-peer-dns': dhcpEntry['use-peer-dns'] ?? 'true',
'use-peer-ntp': dhcpEntry['use-peer-ntp'] ?? 'true',
'add-default-route': dhcpEntry['add-default-route'] ?? 'true',
})
}
}, [dhcpEntry])
useEffect(() => {
if (pppoeEntry) {
setPppoeForm({
interface: pppoeEntry.interface ?? '',
user: pppoeEntry.user ?? '',
password: '',
'service-name': pppoeEntry['service-name'] ?? '',
'use-peer-dns': pppoeEntry['use-peer-dns'] ?? 'true',
})
}
}, [pppoeEntry])
useEffect(() => {
setWanType(detectedWanType)
}, [detectedWanType])
// Interface options for selects
const interfaceOptions = interfaces.entries
.filter((e) => e.type !== 'bridge' && e.type !== 'loopback')
.map((e) => ({ value: e.name ?? '', label: `${e.name} (${e.type ?? 'unknown'})` }))
// Default route for status display
const defaultRoute = routes.entries.find((r) => r['dst-address'] === '0.0.0.0/0')
const isLoading = dhcpClient.isLoading || pppoeClient.isLoading || interfaces.isLoading
// Stage changes
const stageChanges = () => {
if (wanType === 'dhcp') {
const props: Record<string, string> = { ...dhcpForm }
if (dhcpEntry) {
panel.addChange({
operation: 'set',
path: '/ip/dhcp-client',
entryId: dhcpEntry['.id'],
properties: props,
description: `Update DHCP client on ${dhcpForm.interface}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/ip/dhcp-client',
properties: props,
description: `Add DHCP client on ${dhcpForm.interface}`,
})
}
} else if (wanType === 'pppoe') {
const props: Record<string, string> = {}
if (pppoeForm.user) props.user = pppoeForm.user
if (pppoeForm.password) props.password = pppoeForm.password
if (pppoeForm.interface) props.interface = pppoeForm.interface
if (pppoeForm['service-name']) props['service-name'] = pppoeForm['service-name']
props['use-peer-dns'] = pppoeForm['use-peer-dns']
if (pppoeEntry) {
panel.addChange({
operation: 'set',
path: '/interface/pppoe-client',
entryId: pppoeEntry['.id'],
properties: props,
description: `Update PPPoE client settings`,
})
} else {
props.name = 'pppoe-out1'
panel.addChange({
operation: 'add',
path: '/interface/pppoe-client',
properties: props,
description: `Add PPPoE client on ${pppoeForm.interface}`,
})
}
} else {
// Static IP
if (staticForm.address && staticForm.interface) {
panel.addChange({
operation: 'add',
path: '/ip/address',
properties: {
address: staticForm.address,
interface: staticForm.interface,
},
description: `Set static WAN IP ${staticForm.address} on ${staticForm.interface}`,
})
}
if (staticForm.gateway) {
panel.addChange({
operation: 'add',
path: '/ip/route',
properties: {
'dst-address': '0.0.0.0/0',
gateway: staticForm.gateway,
},
description: `Set default gateway to ${staticForm.gateway}`,
})
}
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-text-muted">
Loading Internet configuration...
</div>
)
}
return (
<div className="space-y-6">
<SimpleStatusBanner
items={[
{ label: 'Connection', value: wanType.toUpperCase() },
{ label: 'WAN IP', value: dhcpEntry?.address ?? pppoeEntry?.['local-address'] ?? 'Not configured' },
{ label: 'Gateway', value: defaultRoute?.gateway ?? '\u2014' },
]}
isLoading={isLoading}
/>
<SimpleFormSection icon={Globe} title="Connection Type" description="How this router connects to the internet">
{/* WAN type selector */}
<div className="flex gap-2">
{WAN_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant="outline"
size="sm"
onClick={() => setWanType(opt.value)}
className={cn(
'flex-1',
wanType === opt.value && 'bg-accent/20 text-accent border-accent/40',
)}
>
{opt.label}
</Button>
))}
</div>
{/* DHCP fields */}
{wanType === 'dhcp' && (
<div className="space-y-3 pt-2">
<SimpleFormField
field={{ key: 'interface', label: 'WAN Interface', type: 'select', required: true, options: interfaceOptions }}
value={dhcpForm.interface}
onChange={(v) => setDhcpForm((f) => ({ ...f, interface: v }))}
/>
<SimpleFormField
field={{ key: 'use-peer-dns', label: 'Use ISP DNS', type: 'boolean', help: 'Accept DNS servers from your ISP' }}
value={dhcpForm['use-peer-dns']}
onChange={(v) => setDhcpForm((f) => ({ ...f, 'use-peer-dns': v }))}
/>
<SimpleFormField
field={{ key: 'use-peer-ntp', label: 'Use ISP NTP', type: 'boolean', help: 'Accept time servers from your ISP' }}
value={dhcpForm['use-peer-ntp']}
onChange={(v) => setDhcpForm((f) => ({ ...f, 'use-peer-ntp': v }))}
/>
<SimpleFormField
field={{ key: 'add-default-route', label: 'Add Default Route', type: 'boolean', help: 'Automatically create a default route via this connection' }}
value={dhcpForm['add-default-route']}
onChange={(v) => setDhcpForm((f) => ({ ...f, 'add-default-route': v }))}
/>
</div>
)}
{/* PPPoE fields */}
{wanType === 'pppoe' && (
<div className="space-y-3 pt-2">
<SimpleFormField
field={{ key: 'interface', label: 'Interface', type: 'select', required: true, options: interfaceOptions }}
value={pppoeForm.interface}
onChange={(v) => setPppoeForm((f) => ({ ...f, interface: v }))}
/>
<SimpleFormField
field={{ key: 'user', label: 'PPPoE Username', type: 'text', required: true, placeholder: 'ISP username' }}
value={pppoeForm.user}
onChange={(v) => setPppoeForm((f) => ({ ...f, user: v }))}
/>
<SimpleFormField
field={{ key: 'password', label: 'PPPoE Password', type: 'password', required: true }}
value={pppoeForm.password}
onChange={(v) => setPppoeForm((f) => ({ ...f, password: v }))}
/>
<SimpleFormField
field={{ key: 'service-name', label: 'Service Name', type: 'text', placeholder: 'Optional' }}
value={pppoeForm['service-name']}
onChange={(v) => setPppoeForm((f) => ({ ...f, 'service-name': v }))}
/>
<SimpleFormField
field={{ key: 'use-peer-dns', label: 'Use ISP DNS', type: 'boolean' }}
value={pppoeForm['use-peer-dns']}
onChange={(v) => setPppoeForm((f) => ({ ...f, 'use-peer-dns': v }))}
/>
</div>
)}
{/* Static IP fields */}
{wanType === 'static' && (
<div className="space-y-3 pt-2">
<SimpleFormField
field={{ key: 'interface', label: 'WAN Interface', type: 'select', required: true, options: interfaceOptions }}
value={staticForm.interface}
onChange={(v) => setStaticForm((f) => ({ ...f, interface: v }))}
/>
<SimpleFormField
field={{ key: 'address', label: 'IP Address / Mask', type: 'cidr', required: true, placeholder: '192.168.1.100/24' }}
value={staticForm.address}
onChange={(v) => setStaticForm((f) => ({ ...f, address: v }))}
/>
<SimpleFormField
field={{ key: 'gateway', label: 'Gateway', type: 'ip', required: true, placeholder: '192.168.1.1' }}
value={staticForm.gateway}
onChange={(v) => setStaticForm((f) => ({ ...f, gateway: v }))}
/>
<SimpleFormField
field={{ key: 'dns-servers', label: 'DNS Servers', type: 'text', placeholder: '8.8.8.8,8.8.4.4' }}
value={staticForm['dns-servers']}
onChange={(v) => setStaticForm((f) => ({ ...f, 'dns-servers': v }))}
/>
</div>
)}
<div className="pt-2">
<Button size="sm" variant="outline" onClick={stageChanges}>
Stage Changes
</Button>
</div>
</SimpleFormSection>
<SimpleApplyBar
pendingCount={panel.pendingChanges.length}
isApplying={panel.isApplying}
onReviewClick={() => setPreviewOpen(true)}
/>
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }}
isApplying={panel.isApplying}
/>
</div>
)
}

View File

@@ -0,0 +1,217 @@
/**
* LanDhcpPanel -- Simple mode LAN address and DHCP server configuration.
*
* Shows LAN address, DHCP server settings, pool range, and active leases.
*/
import { useState, useEffect } from 'react'
import { Network, Server } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
import { SimpleFormField } from '../SimpleFormField'
import { SimpleFormSection } from '../SimpleFormSection'
import { SimpleStatusBanner } from '../SimpleStatusBanner'
import { SimpleApplyBar } from '../SimpleApplyBar'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
export function LanDhcpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const addresses = useConfigBrowse(tenantId, deviceId, '/ip/address', { enabled: active })
const dhcpServer = useConfigBrowse(tenantId, deviceId, '/ip/dhcp-server', { enabled: active })
const dhcpNetwork = useConfigBrowse(tenantId, deviceId, '/ip/dhcp-server/network', { enabled: active })
const pools = useConfigBrowse(tenantId, deviceId, '/ip/pool', { enabled: active })
const leases = useConfigBrowse(tenantId, deviceId, '/ip/dhcp-server/lease', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'simple-lan')
const [previewOpen, setPreviewOpen] = useState(false)
// Find the bridge/LAN address
const lanEntry = addresses.entries.find(
(e) => e.interface?.includes('bridge') || e.interface?.includes('lan'),
) ?? addresses.entries[0]
const serverEntry = dhcpServer.entries[0]
const networkEntry = dhcpNetwork.entries[0]
const poolEntry = pools.entries[0]
// LAN form state
const [lanAddress, setLanAddress] = useState('')
const [poolRange, setPoolRange] = useState('')
const [dhcpGateway, setDhcpGateway] = useState('')
const [dhcpDns, setDhcpDns] = useState('')
const [leaseTime, setLeaseTime] = useState('')
// Sync from browse data
useEffect(() => {
if (lanEntry) setLanAddress(lanEntry.address ?? '')
}, [lanEntry])
useEffect(() => {
if (poolEntry) setPoolRange(poolEntry.ranges ?? '')
}, [poolEntry])
useEffect(() => {
if (networkEntry) {
setDhcpGateway(networkEntry.gateway ?? '')
setDhcpDns(networkEntry['dns-server'] ?? '')
setLeaseTime(networkEntry['lease-time'] ?? '')
}
}, [networkEntry])
const isLoading = addresses.isLoading || dhcpServer.isLoading
const stageChanges = () => {
// LAN address
if (lanEntry && lanAddress !== lanEntry.address) {
panel.addChange({
operation: 'set',
path: '/ip/address',
entryId: lanEntry['.id'],
properties: { address: lanAddress },
description: `Update LAN address to ${lanAddress}`,
})
}
// Pool range
if (poolEntry && poolRange !== poolEntry.ranges) {
panel.addChange({
operation: 'set',
path: '/ip/pool',
entryId: poolEntry['.id'],
properties: { ranges: poolRange },
description: `Update DHCP pool range to ${poolRange}`,
})
}
// DHCP network settings
if (networkEntry) {
const props: Record<string, string> = {}
if (dhcpGateway !== networkEntry.gateway) props.gateway = dhcpGateway
if (dhcpDns !== networkEntry['dns-server']) props['dns-server'] = dhcpDns
if (leaseTime !== networkEntry['lease-time']) props['lease-time'] = leaseTime
if (Object.keys(props).length > 0) {
panel.addChange({
operation: 'set',
path: '/ip/dhcp-server/network',
entryId: networkEntry['.id'],
properties: props,
description: 'Update DHCP network settings',
})
}
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-text-muted">
Loading LAN configuration...
</div>
)
}
const activeLeases = leases.entries.filter((l) => l.status === 'bound' || l.status === 'waiting')
return (
<div className="space-y-6">
<SimpleStatusBanner
items={[
{ label: 'LAN IP', value: lanEntry?.address ?? 'Not configured' },
{ label: 'DHCP Server', value: serverEntry?.disabled === 'true' ? 'Disabled' : serverEntry ? 'Enabled' : 'Not configured' },
{ label: 'Pool', value: poolEntry?.ranges ?? '\u2014' },
{ label: 'Active Leases', value: String(activeLeases.length) },
]}
isLoading={isLoading}
/>
<SimpleFormSection icon={Network} title="LAN Address" description="The IP address of this router on the local network">
<SimpleFormField
field={{ key: 'address', label: 'IP Address / Mask', type: 'cidr', required: true, placeholder: '192.168.88.1/24' }}
value={lanAddress}
onChange={setLanAddress}
/>
{lanEntry?.interface && (
<div className="flex items-center gap-2 text-xs text-text-muted">
<span>Interface:</span>
<span className="font-mono text-text-secondary">{lanEntry.interface}</span>
</div>
)}
</SimpleFormSection>
<SimpleFormSection icon={Server} title="DHCP Server" description="Automatically assign IP addresses to network clients">
<SimpleFormField
field={{ key: 'ranges', label: 'Address Pool', type: 'text', placeholder: '192.168.88.10-192.168.88.254', help: 'Range of IP addresses for DHCP clients' }}
value={poolRange}
onChange={setPoolRange}
/>
<SimpleFormField
field={{ key: 'gateway', label: 'Gateway', type: 'ip', placeholder: '192.168.88.1' }}
value={dhcpGateway}
onChange={setDhcpGateway}
/>
<SimpleFormField
field={{ key: 'dns-server', label: 'DNS Servers', type: 'text', placeholder: '192.168.88.1', help: 'DNS servers provided to DHCP clients' }}
value={dhcpDns}
onChange={setDhcpDns}
/>
<SimpleFormField
field={{ key: 'lease-time', label: 'Lease Time', type: 'text', placeholder: '10m', help: 'How long a DHCP lease is valid (e.g., 10m, 1h, 1d)' }}
value={leaseTime}
onChange={setLeaseTime}
/>
<div className="pt-2">
<Button size="sm" variant="outline" onClick={stageChanges}>
Stage Changes
</Button>
</div>
</SimpleFormSection>
{/* Active Leases (read-only) */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-text-secondary">
Active Leases ({activeLeases.length})
</h3>
{activeLeases.length === 0 ? (
<p className="text-xs text-text-muted">No active DHCP leases</p>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="text-left px-3 py-2 font-medium text-text-muted">Hostname</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">IP</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">MAC</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Expires</th>
</tr>
</thead>
<tbody>
{activeLeases.map((lease) => (
<tr key={lease['.id']} className="border-b border-border/30 last:border-0">
<td className="px-3 py-1.5 text-text-primary">{lease['host-name'] || '\u2014'}</td>
<td className="px-3 py-1.5 font-mono text-text-secondary">{lease.address}</td>
<td className="px-3 py-1.5 font-mono text-text-muted">{lease['mac-address']}</td>
<td className="px-3 py-1.5 text-text-muted">{lease['expires-after'] || '\u2014'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<SimpleApplyBar
pendingCount={panel.pendingChanges.length}
isApplying={panel.isApplying}
onReviewClick={() => setPreviewOpen(true)}
/>
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }}
isApplying={panel.isApplying}
/>
</div>
)
}

View File

@@ -0,0 +1,347 @@
/**
* PortForwardingPanel -- Simple mode NAT port forwarding configuration.
*
* Shows existing DST-NAT rules in a friendly table and provides add/edit/delete
* dialogs with user-friendly field names (External Port, Internal IP, etc.).
* Auto-sets chain=dstnat and action=dst-nat so users don't need to know RouterOS internals.
*/
import { useState } from 'react'
import { ArrowLeftRight, Plus, Pencil, Trash2, Power, PowerOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
import { SimpleFormSection } from '../SimpleFormSection'
import { SimpleStatusBanner } from '../SimpleStatusBanner'
import { SimpleApplyBar } from '../SimpleApplyBar'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
interface PortForwardForm {
comment: string
protocol: string
'dst-port': string
'to-addresses': string
'to-ports': string
disabled: string
}
const EMPTY_FORM: PortForwardForm = {
comment: '',
protocol: 'tcp',
'dst-port': '',
'to-addresses': '',
'to-ports': '',
disabled: 'false',
}
const PROTOCOL_OPTIONS = [
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
{ value: '6 (tcp), 17 (udp)', label: 'TCP + UDP' },
]
export function PortForwardingPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const natRules = useConfigBrowse(tenantId, deviceId, '/ip/firewall/nat', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'simple-port-forwarding')
const [previewOpen, setPreviewOpen] = useState(false)
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<PortForwardForm>(EMPTY_FORM)
// Filter to dstnat (port forward) rules only
const dstnatRules = natRules.entries.filter((e) => e.chain === 'dstnat')
const hasMasquerade = natRules.entries.some(
(e) => e.chain === 'srcnat' && e.action === 'masquerade',
)
const isLoading = natRules.isLoading
const openAddDialog = () => {
setEditingId(null)
setForm(EMPTY_FORM)
setDialogOpen(true)
}
const openEditDialog = (entry: Record<string, string>) => {
setEditingId(entry['.id'])
setForm({
comment: entry.comment ?? '',
protocol: entry.protocol ?? 'tcp',
'dst-port': entry['dst-port'] ?? '',
'to-addresses': entry['to-addresses'] ?? '',
'to-ports': entry['to-ports'] ?? '',
disabled: entry.disabled ?? 'false',
})
setDialogOpen(true)
}
const handleSave = () => {
const props: Record<string, string> = {
chain: 'dstnat',
action: 'dst-nat',
protocol: form.protocol,
'dst-port': form['dst-port'],
'to-addresses': form['to-addresses'],
'to-ports': form['to-ports'] || form['dst-port'], // default to same port
}
if (form.comment) props.comment = form.comment
if (form.disabled === 'true') props.disabled = 'true'
if (editingId) {
panel.addChange({
operation: 'set',
path: '/ip/firewall/nat',
entryId: editingId,
properties: props,
description: `Update port forward: ${form.comment || form['dst-port']}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/ip/firewall/nat',
properties: props,
description: `Add port forward: ${form.comment || `port ${form['dst-port']}`} -> ${form['to-addresses']}:${form['to-ports'] || form['dst-port']}`,
})
}
setDialogOpen(false)
setForm(EMPTY_FORM)
setEditingId(null)
}
const handleDelete = (entry: Record<string, string>) => {
panel.addChange({
operation: 'remove',
path: '/ip/firewall/nat',
entryId: entry['.id'],
properties: {},
description: `Delete port forward: ${entry.comment || entry['dst-port']}`,
})
}
const handleToggle = (entry: Record<string, string>) => {
const newDisabled = entry.disabled === 'true' ? 'false' : 'true'
panel.addChange({
operation: 'set',
path: '/ip/firewall/nat',
entryId: entry['.id'],
properties: { disabled: newDisabled },
description: `${newDisabled === 'true' ? 'Disable' : 'Enable'} port forward: ${entry.comment || entry['dst-port']}`,
})
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-text-muted">
Loading port forwarding rules...
</div>
)
}
return (
<div className="space-y-6">
<SimpleStatusBanner
items={[
{ label: 'Port Forwards', value: String(dstnatRules.length) },
{ label: 'NAT Masquerade', value: hasMasquerade ? 'Active' : 'Not configured' },
]}
isLoading={isLoading}
/>
<SimpleFormSection
icon={ArrowLeftRight}
title="Port Forwarding Rules"
description="Forward external traffic to internal network devices"
>
{dstnatRules.length === 0 ? (
<p className="text-xs text-text-muted">
No port forwarding rules configured. Add a rule to allow external access to internal services.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="text-left px-3 py-2 font-medium text-text-muted">Name</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Ext Port</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Protocol</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Internal IP</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Int Port</th>
<th className="text-left px-3 py-2 font-medium text-text-muted">Status</th>
<th className="text-right px-3 py-2 font-medium text-text-muted">Actions</th>
</tr>
</thead>
<tbody>
{dstnatRules.map((entry) => (
<tr key={entry['.id']} className="border-b border-border/30 last:border-0">
<td className="px-3 py-1.5 text-text-primary">
{entry.comment || <span className="text-text-muted italic">Unnamed</span>}
</td>
<td className="px-3 py-1.5 font-mono text-text-secondary">{entry['dst-port'] ?? '\u2014'}</td>
<td className="px-3 py-1.5 text-text-secondary uppercase">{entry.protocol ?? 'tcp'}</td>
<td className="px-3 py-1.5 font-mono text-text-secondary">{entry['to-addresses'] ?? '\u2014'}</td>
<td className="px-3 py-1.5 font-mono text-text-secondary">{entry['to-ports'] ?? entry['dst-port'] ?? '\u2014'}</td>
<td className="px-3 py-1.5">
{entry.disabled === 'true' ? (
<span className="inline-flex items-center gap-1 text-text-muted">
<span className="h-1.5 w-1.5 rounded-full bg-text-muted" />
Disabled
</span>
) : (
<span className="inline-flex items-center gap-1 text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
Active
</span>
)}
</td>
<td className="px-3 py-1.5 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleToggle(entry)}
title={entry.disabled === 'true' ? 'Enable' : 'Disable'}
>
{entry.disabled === 'true' ? (
<Power className="h-3 w-3" />
) : (
<PowerOff className="h-3 w-3" />
)}
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => openEditDialog(entry)}>
<Pencil className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-error" onClick={() => handleDelete(entry)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button size="sm" variant="outline" onClick={openAddDialog} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add Port Forward
</Button>
</SimpleFormSection>
<SimpleApplyBar
pendingCount={panel.pendingChanges.length}
isApplying={panel.isApplying}
onReviewClick={() => setPreviewOpen(true)}
/>
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }}
isApplying={panel.isApplying}
/>
{/* Add/Edit dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{editingId ? 'Edit Port Forward' : 'Add Port Forward'}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-sm">Name / Description</Label>
<Input
value={form.comment}
onChange={(e) => setForm((f) => ({ ...f, comment: e.target.value }))}
placeholder="e.g., Web Server"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-sm">Protocol <span className="text-error">*</span></Label>
<Select value={form.protocol} onValueChange={(v) => setForm((f) => ({ ...f, protocol: v }))}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROTOCOL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-sm">External Port <span className="text-error">*</span></Label>
<Input
type="number"
min={1}
max={65535}
value={form['dst-port']}
onChange={(e) => setForm((f) => ({ ...f, 'dst-port': e.target.value }))}
placeholder="80"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-sm">Internal IP Address <span className="text-error">*</span></Label>
<Input
value={form['to-addresses']}
onChange={(e) => setForm((f) => ({ ...f, 'to-addresses': e.target.value }))}
placeholder="192.168.88.100"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-sm">Internal Port <span className="text-error">*</span></Label>
<Input
type="number"
min={1}
max={65535}
value={form['to-ports']}
onChange={(e) => setForm((f) => ({ ...f, 'to-ports': e.target.value }))}
placeholder="Same as external port"
className="h-8 text-sm"
/>
<p className="text-xs text-text-muted">Leave blank to use the same port as external</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!form['dst-port'] || !form['to-addresses']}
>
{editingId ? 'Save' : 'Add'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,299 @@
/**
* SystemSimplePanel -- Simple mode system configuration.
*
* Covers device identity (hostname), NTP/timezone settings, read-only system
* resource information, and a maintenance section with a reboot button.
*/
import { useState, useEffect } from 'react'
import { Settings, Clock, Info, AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
import { SimpleFormField } from '../SimpleFormField'
import { SimpleFormSection } from '../SimpleFormSection'
import { SimpleStatusBanner } from '../SimpleStatusBanner'
import { SimpleApplyBar } from '../SimpleApplyBar'
import { configEditorApi } from '@/lib/configEditorApi'
import { toast } from 'sonner'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
function formatBytes(bytes: string | undefined): string {
if (!bytes) return '\u2014'
const num = parseInt(bytes, 10)
if (isNaN(num)) return bytes
const mb = (num / 1024 / 1024).toFixed(1)
return `${mb} MB`
}
export function SystemSimplePanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const identity = useConfigBrowse(tenantId, deviceId, '/system/identity', { enabled: active })
const clock = useConfigBrowse(tenantId, deviceId, '/system/clock', { enabled: active })
const ntpClient = useConfigBrowse(tenantId, deviceId, '/system/ntp/client', { enabled: active })
const resource = useConfigBrowse(tenantId, deviceId, '/system/resource', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'simple-system')
const [previewOpen, setPreviewOpen] = useState(false)
const [rebootDialogOpen, setRebootDialogOpen] = useState(false)
const [rebooting, setRebooting] = useState(false)
const identityEntry = identity.entries[0]
const clockEntry = clock.entries[0]
const ntpEntry = ntpClient.entries[0]
const resourceEntry = resource.entries[0]
// Identity form
const [hostname, setHostname] = useState('')
// Clock/NTP form
const [timezone, setTimezone] = useState('')
const [ntpEnabled, setNtpEnabled] = useState('true')
const [ntpServers, setNtpServers] = useState('')
// Sync from browse data
useEffect(() => {
if (identityEntry) setHostname(identityEntry.name ?? '')
}, [identityEntry])
useEffect(() => {
if (clockEntry) setTimezone(clockEntry['time-zone-name'] ?? '')
}, [clockEntry])
useEffect(() => {
if (ntpEntry) {
setNtpEnabled(ntpEntry.enabled ?? 'true')
setNtpServers(ntpEntry['server-dns-names'] ?? '')
}
}, [ntpEntry])
const isLoading = identity.isLoading || clock.isLoading || resource.isLoading
const stageIdentityChanges = () => {
if (identityEntry && hostname !== (identityEntry.name ?? '')) {
panel.addChange({
operation: 'set',
path: '/system/identity',
entryId: identityEntry['.id'],
properties: { name: hostname },
description: `Rename device to "${hostname}"`,
})
}
}
const stageTimeChanges = () => {
// Clock timezone
if (clockEntry && timezone !== (clockEntry['time-zone-name'] ?? '')) {
panel.addChange({
operation: 'set',
path: '/system/clock',
entryId: clockEntry['.id'],
properties: { 'time-zone-name': timezone },
description: `Set timezone to ${timezone}`,
})
}
// NTP settings
const ntpProps: Record<string, string> = {}
if (ntpEntry) {
if (ntpEnabled !== (ntpEntry.enabled ?? 'true')) ntpProps.enabled = ntpEnabled
if (ntpServers !== (ntpEntry['server-dns-names'] ?? '')) ntpProps['server-dns-names'] = ntpServers
}
if (Object.keys(ntpProps).length > 0) {
panel.addChange({
operation: 'set',
path: '/system/ntp/client',
entryId: ntpEntry?.['.id'],
properties: ntpProps,
description: `Update NTP settings`,
})
}
}
const handleReboot = async () => {
setRebooting(true)
try {
await configEditorApi.execute(tenantId, deviceId, '/system/reboot')
toast.success(`Reboot command sent to ${hostname || 'device'}`)
} catch {
toast.error('Failed to send reboot command')
} finally {
setRebooting(false)
setRebootDialogOpen(false)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-text-muted">
Loading system configuration...
</div>
)
}
return (
<div className="space-y-6">
<SimpleStatusBanner
items={[
{ label: 'Hostname', value: identityEntry?.name ?? 'Unknown' },
{ label: 'RouterOS', value: resourceEntry?.version ?? '\u2014' },
{ label: 'Board', value: resourceEntry?.['board-name'] ?? '\u2014' },
{ label: 'Uptime', value: resourceEntry?.uptime ?? '\u2014' },
]}
isLoading={isLoading}
/>
{/* Device Identity */}
<SimpleFormSection icon={Settings} title="Device Identity" description="Set the router's name and identification">
<SimpleFormField
field={{
key: 'name',
label: 'Hostname',
type: 'text',
required: true,
placeholder: 'e.g., Office-Router-1',
help: 'A friendly name for this router, visible in the fleet dashboard',
}}
value={hostname}
onChange={setHostname}
/>
<div className="pt-2">
<Button size="sm" variant="outline" onClick={stageIdentityChanges}>
Stage Changes
</Button>
</div>
</SimpleFormSection>
{/* Time & NTP */}
<SimpleFormSection icon={Clock} title="Time & NTP" description="Configure the system clock and time synchronization">
<SimpleFormField
field={{
key: 'timezone',
label: 'Timezone',
type: 'text',
placeholder: 'America/New_York',
help: 'IANA timezone identifier (e.g., America/New_York, Europe/London, Asia/Tokyo)',
}}
value={timezone}
onChange={setTimezone}
/>
<SimpleFormField
field={{ key: 'ntp-enabled', label: 'NTP Enabled', type: 'boolean', help: 'Synchronize time from NTP servers' }}
value={ntpEnabled}
onChange={setNtpEnabled}
/>
<SimpleFormField
field={{
key: 'ntp-servers',
label: 'NTP Servers',
type: 'text',
placeholder: 'pool.ntp.org',
help: 'Comma-separated NTP server hostnames',
}}
value={ntpServers}
onChange={setNtpServers}
/>
{clockEntry?.time && (
<div className="flex items-center gap-2 text-xs text-text-muted pt-1">
<Clock className="h-3 w-3" />
<span>Current time: {clockEntry.date} {clockEntry.time}</span>
</div>
)}
<div className="pt-2">
<Button size="sm" variant="outline" onClick={stageTimeChanges}>
Stage Changes
</Button>
</div>
</SimpleFormSection>
{/* System Info (read-only) */}
<SimpleFormSection icon={Info} title="System Information" description="Current system information (read-only)">
<div className="space-y-0">
<InfoRow label="Board" value={resourceEntry?.['board-name']} />
<InfoRow label="RouterOS Version" value={resourceEntry?.version} />
<InfoRow label="Architecture" value={resourceEntry?.['architecture-name']} />
{resourceEntry?.cpu && <InfoRow label="CPU" value={resourceEntry.cpu} />}
<InfoRow label="Total Memory" value={formatBytes(resourceEntry?.['total-memory'])} />
<InfoRow label="Free Memory" value={formatBytes(resourceEntry?.['free-memory'])} />
<InfoRow label="Total Disk" value={formatBytes(resourceEntry?.['total-hdd-space'])} />
<InfoRow label="Free Disk" value={formatBytes(resourceEntry?.['free-hdd-space'])} />
<InfoRow label="Uptime" value={resourceEntry?.uptime} />
</div>
</SimpleFormSection>
{/* Maintenance */}
<SimpleFormSection icon={AlertTriangle} title="Maintenance" description="System maintenance actions">
<div className="flex items-center gap-3">
<Button
variant="destructive"
size="sm"
onClick={() => setRebootDialogOpen(true)}
>
Reboot Device
</Button>
<span className="text-xs text-text-muted">
Device will be unreachable for 30-90 seconds
</span>
</div>
</SimpleFormSection>
<SimpleApplyBar
pendingCount={panel.pendingChanges.length}
isApplying={panel.isApplying}
onReviewClick={() => setPreviewOpen(true)}
/>
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }}
isApplying={panel.isApplying}
/>
{/* Reboot confirmation dialog */}
<Dialog open={rebootDialogOpen} onOpenChange={setRebootDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Reboot Device</DialogTitle>
<DialogDescription>
Are you sure you want to reboot {hostname || 'this device'}? The device will be unreachable for 30-90 seconds.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setRebootDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleReboot}
disabled={rebooting}
>
{rebooting ? 'Rebooting...' : 'Confirm Reboot'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function InfoRow({ label, value }: { label: string; value?: string }) {
return (
<div className="flex justify-between py-1.5 border-b border-border/30 last:border-0">
<span className="text-xs text-text-muted">{label}</span>
<span className="text-sm font-mono text-text-primary">{value ?? '\u2014'}</span>
</div>
)
}

View File

@@ -0,0 +1,264 @@
/**
* WifiSimplePanel -- Simplified wireless configuration for Simple mode.
*
* RouterOS version-aware: uses /interface/wireless for v6, /interface/wifi for v7+.
* Shows "no wireless hardware" for devices without WiFi.
*/
import { useState, useEffect } from 'react'
import { Wifi } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
import { SimpleFormField } from '../SimpleFormField'
import { SimpleFormSection } from '../SimpleFormSection'
import { SimpleStatusBanner } from '../SimpleStatusBanner'
import { SimpleApplyBar } from '../SimpleApplyBar'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
interface WifiSimplePanelProps extends ConfigPanelProps {
routerosVersion?: string | null
}
export function WifiSimplePanel({ tenantId, deviceId, active, routerosVersion }: WifiSimplePanelProps) {
const majorVersion = routerosVersion ? parseInt(routerosVersion.split('.')[0], 10) : 7
const isV7 = majorVersion >= 7
const wirelessPath = isV7 ? '/interface/wifi' : '/interface/wireless'
const wireless = useConfigBrowse(tenantId, deviceId, wirelessPath, { enabled: active })
const securityProfiles = useConfigBrowse(tenantId, deviceId, '/interface/wireless/security-profiles', { enabled: active && !isV7 })
const panel = useConfigPanel(tenantId, deviceId, 'simple-wifi')
const [previewOpen, setPreviewOpen] = useState(false)
// Per-interface form state keyed by .id
const [formState, setFormState] = useState<Record<string, Record<string, string>>>({})
// Sync form state from browse data
useEffect(() => {
const newState: Record<string, Record<string, string>> = {}
wireless.entries.forEach((entry) => {
const id = entry['.id']
if (id && !formState[id]) {
if (isV7) {
newState[id] = {
ssid: entry.ssid ?? entry['configuration.ssid'] ?? '',
passphrase: '',
band: entry['configuration.band'] ?? entry.band ?? '',
disabled: entry.disabled ?? 'false',
}
} else {
newState[id] = {
ssid: entry.ssid ?? '',
band: entry.band ?? '',
'channel-width': entry['channel-width'] ?? '',
frequency: entry.frequency ?? '',
'security-profile': entry['security-profile'] ?? '',
disabled: entry.disabled ?? 'false',
}
}
}
})
if (Object.keys(newState).length > 0) {
setFormState((prev) => ({ ...prev, ...newState }))
}
}, [wireless.entries, isV7])
const updateField = (id: string, key: string, value: string) => {
setFormState((prev) => ({
...prev,
[id]: { ...prev[id], [key]: value },
}))
}
const stageInterfaceChanges = (entry: Record<string, string>) => {
const id = entry['.id']
const form = formState[id]
if (!form) return
const props: Record<string, string> = {}
if (isV7) {
if (form.ssid !== (entry.ssid ?? entry['configuration.ssid'] ?? '')) {
props.ssid = form.ssid
}
if (form.passphrase) {
props['security.passphrase'] = form.passphrase
}
if (form.band !== (entry['configuration.band'] ?? entry.band ?? '')) {
props['configuration.band'] = form.band
}
if (form.disabled !== (entry.disabled ?? 'false')) {
props.disabled = form.disabled
}
} else {
if (form.ssid !== (entry.ssid ?? '')) props.ssid = form.ssid
if (form.band !== (entry.band ?? '')) props.band = form.band
if (form['channel-width'] !== (entry['channel-width'] ?? '')) props['channel-width'] = form['channel-width']
if (form.frequency !== (entry.frequency ?? '')) props.frequency = form.frequency
if (form['security-profile'] !== (entry['security-profile'] ?? '')) props['security-profile'] = form['security-profile']
if (form.disabled !== (entry.disabled ?? 'false')) props.disabled = form.disabled
}
if (Object.keys(props).length > 0) {
panel.addChange({
operation: 'set',
path: wirelessPath,
entryId: id,
properties: props,
description: `Update wireless ${entry.name ?? id}: ${Object.keys(props).join(', ')}`,
})
}
}
const isLoading = wireless.isLoading
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-text-muted">
Loading WiFi configuration...
</div>
)
}
// No wireless hardware
if (wireless.entries.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-12 text-center">
<Wifi className="h-8 w-8 text-text-muted/50 mx-auto mb-3" />
<p className="text-sm font-medium text-text-secondary">
This device does not have wireless hardware
</p>
<p className="text-xs text-text-muted mt-1">
WiFi settings are only available on MikroTik devices with built-in wireless or wireless cards.
</p>
</div>
)
}
const firstEntry = wireless.entries[0]
const ssidDisplay = isV7
? firstEntry.ssid ?? firstEntry['configuration.ssid'] ?? 'Unknown'
: firstEntry.ssid ?? 'Unknown'
const secProfileOptions = securityProfiles.entries.map((p) => ({
value: p.name ?? '',
label: p.name ?? '',
}))
return (
<div className="space-y-6">
<SimpleStatusBanner
items={[
{ label: 'SSID', value: ssidDisplay },
{ label: 'Band', value: firstEntry.band ?? firstEntry['configuration.band'] ?? '\u2014' },
{ label: 'Status', value: firstEntry.disabled === 'true' ? 'Disabled' : 'Enabled' },
...(wireless.entries.length > 1 ? [{ label: 'Interfaces', value: `${wireless.entries.length} total` }] : []),
]}
isLoading={isLoading}
/>
{wireless.entries.map((entry) => {
const id = entry['.id']
const form = formState[id] ?? {}
const name = entry.name ?? entry['default-name'] ?? id
return (
<SimpleFormSection key={id} icon={Wifi} title={name} description={isV7 ? 'RouterOS 7 WiFi interface' : 'Wireless interface'}>
<SimpleFormField
field={{ key: 'ssid', label: 'Network Name (SSID)', type: 'text', required: true, placeholder: 'MyNetwork' }}
value={form.ssid ?? ''}
onChange={(v) => updateField(id, 'ssid', v)}
/>
{isV7 ? (
<>
<SimpleFormField
field={{ key: 'passphrase', label: 'Password', type: 'password', help: 'WPA2/WPA3 passphrase (min 8 characters)' }}
value={form.passphrase ?? ''}
onChange={(v) => updateField(id, 'passphrase', v)}
/>
<SimpleFormField
field={{
key: 'band', label: 'Band', type: 'select',
options: [
{ value: '2ghz-ax', label: '2.4 GHz (ax)' },
{ value: '5ghz-ax', label: '5 GHz (ax)' },
{ value: '2ghz-n', label: '2.4 GHz (n)' },
{ value: '5ghz-ac', label: '5 GHz (ac)' },
],
}}
value={form.band ?? ''}
onChange={(v) => updateField(id, 'band', v)}
/>
</>
) : (
<>
<SimpleFormField
field={{
key: 'band', label: 'Band', type: 'select',
options: [
{ value: '2ghz-b/g/n', label: '2.4 GHz (b/g/n)' },
{ value: '5ghz-a/n/ac', label: '5 GHz (a/n/ac)' },
],
}}
value={form.band ?? ''}
onChange={(v) => updateField(id, 'band', v)}
/>
<SimpleFormField
field={{
key: 'channel-width', label: 'Channel Width', type: 'select',
options: [
{ value: '20mhz', label: '20 MHz' },
{ value: '20/40mhz-XX', label: '20/40 MHz' },
{ value: '20/40/80mhz-XXXX', label: '20/40/80 MHz' },
],
}}
value={form['channel-width'] ?? ''}
onChange={(v) => updateField(id, 'channel-width', v)}
/>
{secProfileOptions.length > 0 && (
<SimpleFormField
field={{
key: 'security-profile', label: 'Security Profile', type: 'select',
options: secProfileOptions,
}}
value={form['security-profile'] ?? ''}
onChange={(v) => updateField(id, 'security-profile', v)}
/>
)}
</>
)}
<SimpleFormField
field={{ key: 'disabled', label: 'Enabled', type: 'boolean' }}
value={form.disabled === 'true' ? 'false' : 'true'}
onChange={(v) => updateField(id, 'disabled', v === 'true' ? 'false' : 'true')}
/>
<div className="pt-2">
<Button size="sm" variant="outline" onClick={() => stageInterfaceChanges(entry)}>
Stage Changes
</Button>
</div>
</SimpleFormSection>
)
})}
<SimpleApplyBar
pendingCount={panel.pendingChanges.length}
isApplying={panel.isApplying}
onReviewClick={() => setPreviewOpen(true)}
/>
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }}
isApplying={panel.isApplying}
/>
</div>
)
}