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:
35
frontend/src/components/simple-config/SimpleApplyBar.tsx
Normal file
35
frontend/src/components/simple-config/SimpleApplyBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
240
frontend/src/components/simple-config/SimpleConfigView.tsx
Normal file
240
frontend/src/components/simple-config/SimpleConfigView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
frontend/src/components/simple-config/SimpleFormField.tsx
Normal file
132
frontend/src/components/simple-config/SimpleFormField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/simple-config/SimpleFormSection.tsx
Normal file
35
frontend/src/components/simple-config/SimpleFormSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/simple-config/SimpleModeToggle.tsx
Normal file
44
frontend/src/components/simple-config/SimpleModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/simple-config/SimpleStatusBanner.tsx
Normal file
30
frontend/src/components/simple-config/SimpleStatusBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
frontend/src/components/simple-config/StandardConfigSidebar.tsx
Normal file
182
frontend/src/components/simple-config/StandardConfigSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user