1356 lines
48 KiB
TypeScript
1356 lines
48 KiB
TypeScript
/**
|
|
* FirewallPanel — Firewall rule editor with visual builder, table view,
|
|
* action color coding, rule reordering, and NAT management.
|
|
*
|
|
* CFG-03: Displays filter rules and NAT rules in separate sub-tabs,
|
|
* provides a visual rule builder form, and supports move up/down reordering.
|
|
*/
|
|
|
|
import { useState, useCallback } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Plus,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
Trash2,
|
|
Eye,
|
|
EyeOff,
|
|
Shield,
|
|
Network,
|
|
} from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogFooter,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
Select,
|
|
SelectTrigger,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { cn } from '@/lib/utils'
|
|
import { configEditorApi } from '@/lib/configEditorApi'
|
|
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
|
|
import { SafetyToggle } from '@/components/config/SafetyToggle'
|
|
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
|
|
import type { ConfigPanelProps, ConfigChange } from '@/lib/configPanelTypes'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type SubTab = 'filter' | 'nat'
|
|
|
|
type FilterAction = 'accept' | 'drop' | 'reject' | 'log' | 'jump' | 'passthrough'
|
|
type FilterChain = 'input' | 'forward' | 'output'
|
|
type NatChain = 'srcnat' | 'dstnat'
|
|
type NatAction = 'masquerade' | 'dst-nat' | 'src-nat' | 'redirect' | 'netmap'
|
|
type Protocol = 'tcp' | 'udp' | 'icmp' | ''
|
|
|
|
interface FilterFormData {
|
|
chain: FilterChain
|
|
action: FilterAction
|
|
protocol: Protocol
|
|
'src-address': string
|
|
'dst-address': string
|
|
'src-port': string
|
|
'dst-port': string
|
|
'in-interface': string
|
|
'out-interface': string
|
|
comment: string
|
|
disabled: boolean
|
|
}
|
|
|
|
interface NatFormData {
|
|
chain: NatChain
|
|
action: NatAction
|
|
protocol: Protocol
|
|
'src-address': string
|
|
'dst-address': string
|
|
'src-port': string
|
|
'dst-port': string
|
|
'to-addresses': string
|
|
'to-ports': string
|
|
comment: string
|
|
disabled: boolean
|
|
}
|
|
|
|
type FormMode = 'add' | 'edit'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FILTER_PATH = '/ip/firewall/filter'
|
|
const NAT_PATH = '/ip/firewall/nat'
|
|
|
|
const FILTER_CHAINS: FilterChain[] = ['input', 'forward', 'output']
|
|
const FILTER_ACTIONS: FilterAction[] = ['accept', 'drop', 'reject', 'log', 'jump', 'passthrough']
|
|
const NAT_CHAINS: NatChain[] = ['srcnat', 'dstnat']
|
|
const NAT_ACTIONS: NatAction[] = ['masquerade', 'dst-nat', 'src-nat', 'redirect', 'netmap']
|
|
const PROTOCOLS: Protocol[] = ['tcp', 'udp', 'icmp', '']
|
|
|
|
const DEFAULT_FILTER_FORM: FilterFormData = {
|
|
chain: 'input',
|
|
action: 'accept',
|
|
protocol: '',
|
|
'src-address': '',
|
|
'dst-address': '',
|
|
'src-port': '',
|
|
'dst-port': '',
|
|
'in-interface': '',
|
|
'out-interface': '',
|
|
comment: '',
|
|
disabled: false,
|
|
}
|
|
|
|
const DEFAULT_NAT_FORM: NatFormData = {
|
|
chain: 'srcnat',
|
|
action: 'masquerade',
|
|
protocol: '',
|
|
'src-address': '',
|
|
'dst-address': '',
|
|
'src-port': '',
|
|
'dst-port': '',
|
|
'to-addresses': '',
|
|
'to-ports': '',
|
|
comment: '',
|
|
disabled: false,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Color helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getFilterActionColor(action: string): string {
|
|
switch (action) {
|
|
case 'accept':
|
|
return 'border-l-4 border-l-success/60'
|
|
case 'drop':
|
|
return 'border-l-4 border-l-error/60'
|
|
case 'reject':
|
|
return 'border-l-4 border-l-warning/60'
|
|
case 'log':
|
|
return 'border-l-4 border-l-info/60'
|
|
default:
|
|
return 'border-l-4 border-l-border'
|
|
}
|
|
}
|
|
|
|
function getNatActionColor(action: string): string {
|
|
switch (action) {
|
|
case 'masquerade':
|
|
return 'border-l-4 border-l-success/60'
|
|
case 'dst-nat':
|
|
return 'border-l-4 border-l-info/60'
|
|
case 'src-nat':
|
|
return 'border-l-4 border-l-warning/60'
|
|
default:
|
|
return 'border-l-4 border-l-border'
|
|
}
|
|
}
|
|
|
|
function getActionBadgeClasses(action: string): string {
|
|
switch (action) {
|
|
case 'accept':
|
|
case 'masquerade':
|
|
return 'bg-success/15 text-success border-success/30'
|
|
case 'drop':
|
|
return 'bg-error/15 text-error border-error/30'
|
|
case 'reject':
|
|
return 'bg-warning/15 text-warning border-warning/30'
|
|
case 'log':
|
|
case 'dst-nat':
|
|
return 'bg-info/15 text-info border-info/30'
|
|
case 'src-nat':
|
|
case 'redirect':
|
|
return 'bg-warning/15 text-warning border-warning/30'
|
|
default:
|
|
return 'bg-elevated text-text-secondary border-border'
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const IP_CIDR_REGEX = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/
|
|
const PORT_RANGE_REGEX = /^\d{1,5}(-\d{1,5})?$/
|
|
|
|
function validateIpCidr(value: string): string | null {
|
|
if (!value) return null
|
|
if (!IP_CIDR_REGEX.test(value)) {
|
|
return 'Invalid IP address or CIDR notation (e.g., 192.168.1.0/24)'
|
|
}
|
|
const parts = value.split('/')[0].split('.')
|
|
if (parts.some((p) => parseInt(p, 10) > 255)) {
|
|
return 'IP octets must be 0-255'
|
|
}
|
|
const cidr = value.split('/')[1]
|
|
if (cidr && (parseInt(cidr, 10) < 0 || parseInt(cidr, 10) > 32)) {
|
|
return 'CIDR prefix must be 0-32'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function validatePort(value: string): string | null {
|
|
if (!value) return null
|
|
if (!PORT_RANGE_REGEX.test(value)) {
|
|
return 'Invalid port (number or range e.g., 80 or 1024-65535)'
|
|
}
|
|
const parts = value.split('-').map((p) => parseInt(p, 10))
|
|
if (parts.some((p) => p < 0 || p > 65535)) {
|
|
return 'Port must be 0-65535'
|
|
}
|
|
if (parts.length === 2 && parts[0] > parts[1]) {
|
|
return 'Start port must be less than end port'
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FirewallPanel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function FirewallPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
|
const [subTab, setSubTab] = useState<SubTab>('filter')
|
|
const [previewOpen, setPreviewOpen] = useState(false)
|
|
|
|
// Data loading
|
|
const filterQuery = useConfigBrowse(tenantId, deviceId, FILTER_PATH, { enabled: active })
|
|
const natQuery = useConfigBrowse(tenantId, deviceId, NAT_PATH, { enabled: active })
|
|
|
|
// Config panel (change management + apply)
|
|
const panel = useConfigPanel(tenantId, deviceId, 'firewall')
|
|
|
|
// Filter rule form state
|
|
const [filterFormOpen, setFilterFormOpen] = useState(false)
|
|
const [filterFormMode, setFilterFormMode] = useState<FormMode>('add')
|
|
const [filterFormData, setFilterFormData] = useState<FilterFormData>(DEFAULT_FILTER_FORM)
|
|
const [filterEditId, setFilterEditId] = useState<string | null>(null)
|
|
|
|
// NAT rule form state
|
|
const [natFormOpen, setNatFormOpen] = useState(false)
|
|
const [natFormMode, setNatFormMode] = useState<FormMode>('add')
|
|
const [natFormData, setNatFormData] = useState<NatFormData>(DEFAULT_NAT_FORM)
|
|
const [natEditId, setNatEditId] = useState<string | null>(null)
|
|
|
|
// Move in progress
|
|
const [movingId, setMovingId] = useState<string | null>(null)
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Filter rule handlers
|
|
// -------------------------------------------------------------------------
|
|
|
|
const openAddFilter = useCallback(() => {
|
|
setFilterFormMode('add')
|
|
setFilterFormData(DEFAULT_FILTER_FORM)
|
|
setFilterEditId(null)
|
|
setFilterFormOpen(true)
|
|
}, [])
|
|
|
|
const openEditFilter = useCallback((entry: Record<string, string>) => {
|
|
setFilterFormMode('edit')
|
|
setFilterFormData({
|
|
chain: (entry.chain || 'input') as FilterChain,
|
|
action: (entry.action || 'accept') as FilterAction,
|
|
protocol: (entry.protocol || '') as Protocol,
|
|
'src-address': entry['src-address'] || '',
|
|
'dst-address': entry['dst-address'] || '',
|
|
'src-port': entry['src-port'] || '',
|
|
'dst-port': entry['dst-port'] || '',
|
|
'in-interface': entry['in-interface'] || '',
|
|
'out-interface': entry['out-interface'] || '',
|
|
comment: entry.comment || '',
|
|
disabled: entry.disabled === 'true',
|
|
})
|
|
setFilterEditId(entry['.id'] || null)
|
|
setFilterFormOpen(true)
|
|
}, [])
|
|
|
|
const submitFilterForm = useCallback(() => {
|
|
// Validate
|
|
const srcErr = validateIpCidr(filterFormData['src-address'])
|
|
const dstErr = validateIpCidr(filterFormData['dst-address'])
|
|
const srcPortErr = validatePort(filterFormData['src-port'])
|
|
const dstPortErr = validatePort(filterFormData['dst-port'])
|
|
const errors = [srcErr, dstErr, srcPortErr, dstPortErr].filter(Boolean)
|
|
if (errors.length > 0) {
|
|
toast.error('Validation error', { description: errors[0]! })
|
|
return
|
|
}
|
|
|
|
// Build properties (only include non-empty values)
|
|
const props: Record<string, string> = {}
|
|
props.chain = filterFormData.chain
|
|
props.action = filterFormData.action
|
|
if (filterFormData.protocol) props.protocol = filterFormData.protocol
|
|
if (filterFormData['src-address']) props['src-address'] = filterFormData['src-address']
|
|
if (filterFormData['dst-address']) props['dst-address'] = filterFormData['dst-address']
|
|
if (filterFormData['src-port']) props['src-port'] = filterFormData['src-port']
|
|
if (filterFormData['dst-port']) props['dst-port'] = filterFormData['dst-port']
|
|
if (filterFormData['in-interface']) props['in-interface'] = filterFormData['in-interface']
|
|
if (filterFormData['out-interface']) props['out-interface'] = filterFormData['out-interface']
|
|
if (filterFormData.comment) props.comment = filterFormData.comment
|
|
if (filterFormData.disabled) props.disabled = 'true'
|
|
|
|
const change: ConfigChange = {
|
|
operation: filterFormMode === 'add' ? 'add' : 'set',
|
|
path: FILTER_PATH,
|
|
entryId: filterFormMode === 'edit' ? filterEditId ?? undefined : undefined,
|
|
properties: props,
|
|
description:
|
|
filterFormMode === 'add'
|
|
? `Add ${filterFormData.action} rule on ${filterFormData.chain} chain`
|
|
: `Edit filter rule ${filterEditId ?? ''}`,
|
|
}
|
|
|
|
panel.addChange(change)
|
|
setFilterFormOpen(false)
|
|
toast.success(`Filter rule ${filterFormMode === 'add' ? 'added' : 'updated'} in pending changes`)
|
|
}, [filterFormData, filterFormMode, filterEditId, panel])
|
|
|
|
// -------------------------------------------------------------------------
|
|
// NAT rule handlers
|
|
// -------------------------------------------------------------------------
|
|
|
|
const openAddNat = useCallback(() => {
|
|
setNatFormMode('add')
|
|
setNatFormData(DEFAULT_NAT_FORM)
|
|
setNatEditId(null)
|
|
setNatFormOpen(true)
|
|
}, [])
|
|
|
|
const openEditNat = useCallback((entry: Record<string, string>) => {
|
|
setNatFormMode('edit')
|
|
setNatFormData({
|
|
chain: (entry.chain || 'srcnat') as NatChain,
|
|
action: (entry.action || 'masquerade') as NatAction,
|
|
protocol: (entry.protocol || '') as Protocol,
|
|
'src-address': entry['src-address'] || '',
|
|
'dst-address': entry['dst-address'] || '',
|
|
'src-port': entry['src-port'] || '',
|
|
'dst-port': entry['dst-port'] || '',
|
|
'to-addresses': entry['to-addresses'] || '',
|
|
'to-ports': entry['to-ports'] || '',
|
|
comment: entry.comment || '',
|
|
disabled: entry.disabled === 'true',
|
|
})
|
|
setNatEditId(entry['.id'] || null)
|
|
setNatFormOpen(true)
|
|
}, [])
|
|
|
|
const submitNatForm = useCallback(() => {
|
|
// Validate
|
|
const srcErr = validateIpCidr(natFormData['src-address'])
|
|
const dstErr = validateIpCidr(natFormData['dst-address'])
|
|
const srcPortErr = validatePort(natFormData['src-port'])
|
|
const dstPortErr = validatePort(natFormData['dst-port'])
|
|
const errors = [srcErr, dstErr, srcPortErr, dstPortErr].filter(Boolean)
|
|
if (errors.length > 0) {
|
|
toast.error('Validation error', { description: errors[0]! })
|
|
return
|
|
}
|
|
|
|
const props: Record<string, string> = {}
|
|
props.chain = natFormData.chain
|
|
props.action = natFormData.action
|
|
if (natFormData.protocol) props.protocol = natFormData.protocol
|
|
if (natFormData['src-address']) props['src-address'] = natFormData['src-address']
|
|
if (natFormData['dst-address']) props['dst-address'] = natFormData['dst-address']
|
|
if (natFormData['src-port']) props['src-port'] = natFormData['src-port']
|
|
if (natFormData['dst-port']) props['dst-port'] = natFormData['dst-port']
|
|
if (natFormData['to-addresses']) props['to-addresses'] = natFormData['to-addresses']
|
|
if (natFormData['to-ports']) props['to-ports'] = natFormData['to-ports']
|
|
if (natFormData.comment) props.comment = natFormData.comment
|
|
if (natFormData.disabled) props.disabled = 'true'
|
|
|
|
const change: ConfigChange = {
|
|
operation: natFormMode === 'add' ? 'add' : 'set',
|
|
path: NAT_PATH,
|
|
entryId: natFormMode === 'edit' ? natEditId ?? undefined : undefined,
|
|
properties: props,
|
|
description:
|
|
natFormMode === 'add'
|
|
? `Add ${natFormData.action} NAT rule on ${natFormData.chain} chain`
|
|
: `Edit NAT rule ${natEditId ?? ''}`,
|
|
}
|
|
|
|
panel.addChange(change)
|
|
setNatFormOpen(false)
|
|
toast.success(`NAT rule ${natFormMode === 'add' ? 'added' : 'updated'} in pending changes`)
|
|
}, [natFormData, natFormMode, natEditId, panel])
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Shared rule actions
|
|
// -------------------------------------------------------------------------
|
|
|
|
const handleToggleDisable = useCallback(
|
|
(entry: Record<string, string>, path: string) => {
|
|
const isDisabled = entry.disabled === 'true'
|
|
const change: ConfigChange = {
|
|
operation: 'set',
|
|
path,
|
|
entryId: entry['.id'],
|
|
properties: { disabled: isDisabled ? 'false' : 'true' },
|
|
description: `${isDisabled ? 'Enable' : 'Disable'} rule ${entry['.id'] ?? ''}`,
|
|
}
|
|
panel.addChange(change)
|
|
toast.success(`Rule ${isDisabled ? 'enable' : 'disable'} added to pending changes`)
|
|
},
|
|
[panel],
|
|
)
|
|
|
|
const handleDeleteRule = useCallback(
|
|
(entry: Record<string, string>, path: string) => {
|
|
const change: ConfigChange = {
|
|
operation: 'remove',
|
|
path,
|
|
entryId: entry['.id'],
|
|
properties: {},
|
|
description: `Delete rule ${entry['.id'] ?? ''} (${entry.action || 'unknown'} on ${entry.chain || 'unknown'})`,
|
|
}
|
|
panel.addChange(change)
|
|
toast.success('Rule deletion added to pending changes')
|
|
},
|
|
[panel],
|
|
)
|
|
|
|
const handleMoveRule = useCallback(
|
|
async (entry: Record<string, string>, index: number, direction: 'up' | 'down') => {
|
|
const ruleId = entry['.id']
|
|
if (!ruleId) return
|
|
const destination = direction === 'up' ? index - 1 : index + 1
|
|
if (destination < 0) return
|
|
|
|
setMovingId(ruleId)
|
|
try {
|
|
const command = `${FILTER_PATH}/move .id=${ruleId} destination=${destination}`
|
|
const result = await configEditorApi.execute(tenantId, deviceId, command)
|
|
if (!result.success) {
|
|
throw new Error(result.error ?? 'Move failed')
|
|
}
|
|
toast.success(`Rule moved ${direction}`)
|
|
filterQuery.refetch()
|
|
} catch (err) {
|
|
toast.error('Failed to move rule', {
|
|
description: err instanceof Error ? err.message : 'Unknown error',
|
|
})
|
|
} finally {
|
|
setMovingId(null)
|
|
}
|
|
},
|
|
[tenantId, deviceId, filterQuery],
|
|
)
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Apply flow
|
|
// -------------------------------------------------------------------------
|
|
|
|
const handleApply = useCallback(() => {
|
|
panel.applyChanges()
|
|
setPreviewOpen(false)
|
|
}, [panel])
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Render
|
|
// -------------------------------------------------------------------------
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header bar */}
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
{/* Sub-tabs */}
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setSubTab('filter')}
|
|
className={cn(
|
|
'gap-1.5',
|
|
subTab === 'filter' &&
|
|
'bg-accent/20 text-accent border-accent/40 hover:bg-accent/30 hover:text-accent',
|
|
)}
|
|
>
|
|
<Shield className="h-3.5 w-3.5" />
|
|
Filter Rules
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setSubTab('nat')}
|
|
className={cn(
|
|
'gap-1.5',
|
|
subTab === 'nat' &&
|
|
'bg-accent/20 text-accent border-accent/40 hover:bg-accent/30 hover:text-accent',
|
|
)}
|
|
>
|
|
<Network className="h-3.5 w-3.5" />
|
|
NAT Rules
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Safety toggle + apply */}
|
|
<div className="flex items-center gap-3">
|
|
<SafetyToggle mode={panel.applyMode} onModeChange={panel.setApplyMode} />
|
|
<Button
|
|
size="sm"
|
|
disabled={panel.pendingChanges.length === 0 || panel.isApplying}
|
|
onClick={() => setPreviewOpen(true)}
|
|
>
|
|
Review & Apply
|
|
{panel.pendingChanges.length > 0 && (
|
|
<Badge className="ml-1.5 bg-accent/20 text-accent border-accent/40 text-xs px-1.5">
|
|
{panel.pendingChanges.length}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tables */}
|
|
{subTab === 'filter' ? (
|
|
<FilterRulesTable
|
|
entries={filterQuery.entries}
|
|
isLoading={filterQuery.isLoading}
|
|
error={filterQuery.error}
|
|
onRetry={filterQuery.refetch}
|
|
onAdd={openAddFilter}
|
|
onEdit={openEditFilter}
|
|
onToggleDisable={(e) => handleToggleDisable(e, FILTER_PATH)}
|
|
onDelete={(e) => handleDeleteRule(e, FILTER_PATH)}
|
|
onMoveUp={(e, i) => handleMoveRule(e, i, 'up')}
|
|
onMoveDown={(e, i) => handleMoveRule(e, i, 'down')}
|
|
movingId={movingId}
|
|
/>
|
|
) : (
|
|
<NatRulesTable
|
|
entries={natQuery.entries}
|
|
isLoading={natQuery.isLoading}
|
|
error={natQuery.error}
|
|
onRetry={natQuery.refetch}
|
|
onAdd={openAddNat}
|
|
onEdit={openEditNat}
|
|
onToggleDisable={(e) => handleToggleDisable(e, NAT_PATH)}
|
|
onDelete={(e) => handleDeleteRule(e, NAT_PATH)}
|
|
/>
|
|
)}
|
|
|
|
{/* Filter Rule Builder Dialog */}
|
|
<FilterRuleDialog
|
|
open={filterFormOpen}
|
|
onOpenChange={setFilterFormOpen}
|
|
mode={filterFormMode}
|
|
data={filterFormData}
|
|
onChange={setFilterFormData}
|
|
onSubmit={submitFilterForm}
|
|
/>
|
|
|
|
{/* NAT Rule Builder Dialog */}
|
|
<NatRuleDialog
|
|
open={natFormOpen}
|
|
onOpenChange={setNatFormOpen}
|
|
mode={natFormMode}
|
|
data={natFormData}
|
|
onChange={setNatFormData}
|
|
onSubmit={submitNatForm}
|
|
/>
|
|
|
|
{/* Change Preview Modal */}
|
|
<ChangePreviewModal
|
|
open={previewOpen}
|
|
onOpenChange={setPreviewOpen}
|
|
changes={panel.pendingChanges}
|
|
applyMode={panel.applyMode}
|
|
onConfirm={handleApply}
|
|
isApplying={panel.isApplying}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Filter Rules Table
|
|
// ===========================================================================
|
|
|
|
interface FilterRulesTableProps {
|
|
entries: Record<string, string>[]
|
|
isLoading: boolean
|
|
error: Error | null
|
|
onRetry: () => void
|
|
onAdd: () => void
|
|
onEdit: (entry: Record<string, string>) => void
|
|
onToggleDisable: (entry: Record<string, string>) => void
|
|
onDelete: (entry: Record<string, string>) => void
|
|
onMoveUp: (entry: Record<string, string>, index: number) => void
|
|
onMoveDown: (entry: Record<string, string>, index: number) => void
|
|
movingId: string | null
|
|
}
|
|
|
|
function FilterRulesTable({
|
|
entries,
|
|
isLoading,
|
|
error,
|
|
onRetry,
|
|
onAdd,
|
|
onEdit,
|
|
onToggleDisable,
|
|
onDelete,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
movingId,
|
|
}: FilterRulesTableProps) {
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-lg border border-error/30 bg-error/5 p-4 space-y-2">
|
|
<p className="text-sm text-error">
|
|
Failed to load filter rules: {error.message}
|
|
</p>
|
|
<Button size="sm" variant="outline" onClick={onRetry} className="text-xs">
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-text-secondary">
|
|
Filter Rules ({isLoading ? '...' : entries.length})
|
|
</h3>
|
|
<Button size="sm" variant="outline" onClick={onAdd} className="gap-1.5">
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add Rule
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-elevated text-text-muted text-xs uppercase">
|
|
<th className="px-3 py-2 text-left font-medium w-10">#</th>
|
|
<th className="px-3 py-2 text-left font-medium">Chain</th>
|
|
<th className="px-3 py-2 text-left font-medium">Action</th>
|
|
<th className="px-3 py-2 text-left font-medium">Src Address</th>
|
|
<th className="px-3 py-2 text-left font-medium">Dst Address</th>
|
|
<th className="px-3 py-2 text-left font-medium">Protocol</th>
|
|
<th className="px-3 py-2 text-left font-medium">Dst Port</th>
|
|
<th className="px-3 py-2 text-left font-medium">In Iface</th>
|
|
<th className="px-3 py-2 text-left font-medium">Out Iface</th>
|
|
<th className="px-3 py-2 text-left font-medium">Comment</th>
|
|
<th className="px-3 py-2 text-left font-medium w-10">Dis</th>
|
|
<th className="px-3 py-2 text-right font-medium w-12" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading ? (
|
|
<LoadingRows cols={12} />
|
|
) : entries.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={12} className="px-3 py-8 text-center text-text-muted text-sm">
|
|
No filter rules configured
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
entries.map((entry, index) => {
|
|
const isDisabled = entry.disabled === 'true'
|
|
const isMoving = movingId === entry['.id']
|
|
return (
|
|
<tr
|
|
key={entry['.id'] || index}
|
|
className={cn(
|
|
'border-b border-border/50 hover:bg-elevated/50 transition-colors',
|
|
getFilterActionColor(entry.action),
|
|
isDisabled && 'opacity-50',
|
|
isMoving && 'opacity-70',
|
|
)}
|
|
>
|
|
<td className="px-3 py-2 text-text-muted font-mono text-xs">{index + 1}</td>
|
|
<td className="px-3 py-2">{entry.chain || <CellEmpty />}</td>
|
|
<td className="px-3 py-2">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
'text-xs font-medium',
|
|
getActionBadgeClasses(entry.action),
|
|
isDisabled && 'line-through',
|
|
)}
|
|
>
|
|
{entry.action || 'unknown'}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-xs">
|
|
{entry['src-address'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-xs">
|
|
{entry['dst-address'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2">{entry.protocol || <CellEmpty />}</td>
|
|
<td className="px-3 py-2 font-mono text-xs">
|
|
{entry['dst-port'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs">
|
|
{entry['in-interface'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs">
|
|
{entry['out-interface'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-text-secondary max-w-[160px] truncate">
|
|
{entry.comment || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{isDisabled ? (
|
|
<EyeOff className="h-3.5 w-3.5 text-warning inline" />
|
|
) : null}
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => onEdit(entry)}>
|
|
<Pencil className="h-3.5 w-3.5 mr-2" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onToggleDisable(entry)}>
|
|
{isDisabled ? (
|
|
<>
|
|
<Eye className="h-3.5 w-3.5 mr-2" />
|
|
Enable
|
|
</>
|
|
) : (
|
|
<>
|
|
<EyeOff className="h-3.5 w-3.5 mr-2" />
|
|
Disable
|
|
</>
|
|
)}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => onMoveUp(entry, index)}
|
|
disabled={index === 0 || isMoving}
|
|
>
|
|
<ArrowUp className="h-3.5 w-3.5 mr-2" />
|
|
Move Up
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => onMoveDown(entry, index)}
|
|
disabled={index === entries.length - 1 || isMoving}
|
|
>
|
|
<ArrowDown className="h-3.5 w-3.5 mr-2" />
|
|
Move Down
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => onDelete(entry)}
|
|
className="text-error focus:text-error"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// NAT Rules Table
|
|
// ===========================================================================
|
|
|
|
interface NatRulesTableProps {
|
|
entries: Record<string, string>[]
|
|
isLoading: boolean
|
|
error: Error | null
|
|
onRetry: () => void
|
|
onAdd: () => void
|
|
onEdit: (entry: Record<string, string>) => void
|
|
onToggleDisable: (entry: Record<string, string>) => void
|
|
onDelete: (entry: Record<string, string>) => void
|
|
}
|
|
|
|
function NatRulesTable({
|
|
entries,
|
|
isLoading,
|
|
error,
|
|
onRetry,
|
|
onAdd,
|
|
onEdit,
|
|
onToggleDisable,
|
|
onDelete,
|
|
}: NatRulesTableProps) {
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-lg border border-error/30 bg-error/5 p-4 space-y-2">
|
|
<p className="text-sm text-error">
|
|
Failed to load NAT rules: {error.message}
|
|
</p>
|
|
<Button size="sm" variant="outline" onClick={onRetry} className="text-xs">
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-text-secondary">
|
|
NAT Rules ({isLoading ? '...' : entries.length})
|
|
</h3>
|
|
<Button size="sm" variant="outline" onClick={onAdd} className="gap-1.5">
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add NAT Rule
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-elevated text-text-muted text-xs uppercase">
|
|
<th className="px-3 py-2 text-left font-medium w-10">#</th>
|
|
<th className="px-3 py-2 text-left font-medium">Chain</th>
|
|
<th className="px-3 py-2 text-left font-medium">Action</th>
|
|
<th className="px-3 py-2 text-left font-medium">Src Address</th>
|
|
<th className="px-3 py-2 text-left font-medium">Dst Address</th>
|
|
<th className="px-3 py-2 text-left font-medium">Protocol</th>
|
|
<th className="px-3 py-2 text-left font-medium">Dst Port</th>
|
|
<th className="px-3 py-2 text-left font-medium">To Addresses</th>
|
|
<th className="px-3 py-2 text-left font-medium">To Ports</th>
|
|
<th className="px-3 py-2 text-left font-medium">Comment</th>
|
|
<th className="px-3 py-2 text-left font-medium w-10">Dis</th>
|
|
<th className="px-3 py-2 text-right font-medium w-12" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading ? (
|
|
<LoadingRows cols={12} />
|
|
) : entries.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={12} className="px-3 py-8 text-center text-text-muted text-sm">
|
|
No NAT rules configured
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
entries.map((entry, index) => {
|
|
const isDisabled = entry.disabled === 'true'
|
|
return (
|
|
<tr
|
|
key={entry['.id'] || index}
|
|
className={cn(
|
|
'border-b border-border/50 hover:bg-elevated/50 transition-colors',
|
|
getNatActionColor(entry.action),
|
|
isDisabled && 'opacity-50',
|
|
)}
|
|
>
|
|
<td className="px-3 py-2 text-text-muted font-mono text-xs">{index + 1}</td>
|
|
<td className="px-3 py-2">{entry.chain || <CellEmpty />}</td>
|
|
<td className="px-3 py-2">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
'text-xs font-medium',
|
|
getActionBadgeClasses(entry.action),
|
|
isDisabled && 'line-through',
|
|
)}
|
|
>
|
|
{entry.action || 'unknown'}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-xs">
|
|
{entry['src-address'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-xs">
|
|
{entry['dst-address'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2">{entry.protocol || <CellEmpty />}</td>
|
|
<td className="px-3 py-2 font-mono text-xs">
|
|
{entry['dst-port'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-xs">
|
|
{entry['to-addresses'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-xs">
|
|
{entry['to-ports'] || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-text-secondary max-w-[160px] truncate">
|
|
{entry.comment || <CellEmpty />}
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{isDisabled ? (
|
|
<EyeOff className="h-3.5 w-3.5 text-warning inline" />
|
|
) : null}
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => onEdit(entry)}>
|
|
<Pencil className="h-3.5 w-3.5 mr-2" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onToggleDisable(entry)}>
|
|
{isDisabled ? (
|
|
<>
|
|
<Eye className="h-3.5 w-3.5 mr-2" />
|
|
Enable
|
|
</>
|
|
) : (
|
|
<>
|
|
<EyeOff className="h-3.5 w-3.5 mr-2" />
|
|
Disable
|
|
</>
|
|
)}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => onDelete(entry)}
|
|
className="text-error focus:text-error"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Filter Rule Dialog (Visual Builder)
|
|
// ===========================================================================
|
|
|
|
interface FilterRuleDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
mode: FormMode
|
|
data: FilterFormData
|
|
onChange: (data: FilterFormData) => void
|
|
onSubmit: () => void
|
|
}
|
|
|
|
function FilterRuleDialog({
|
|
open,
|
|
onOpenChange,
|
|
mode,
|
|
data,
|
|
onChange,
|
|
onSubmit,
|
|
}: FilterRuleDialogProps) {
|
|
const updateField = <K extends keyof FilterFormData>(key: K, value: FilterFormData[K]) => {
|
|
onChange({ ...data, [key]: value })
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{mode === 'add' ? 'Add Filter Rule' : 'Edit Filter Rule'}</DialogTitle>
|
|
<DialogDescription>
|
|
Configure firewall filter rule properties. Only non-empty fields will be applied.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-2 gap-4 py-2">
|
|
{/* Chain */}
|
|
<div className="space-y-1.5">
|
|
<Label>Chain *</Label>
|
|
<Select value={data.chain} onValueChange={(v) => updateField('chain', v as FilterChain)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{FILTER_CHAINS.map((c) => (
|
|
<SelectItem key={c} value={c}>
|
|
{c}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Action */}
|
|
<div className="space-y-1.5">
|
|
<Label>Action *</Label>
|
|
<Select value={data.action} onValueChange={(v) => updateField('action', v as FilterAction)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{FILTER_ACTIONS.map((a) => (
|
|
<SelectItem key={a} value={a}>
|
|
{a}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Protocol */}
|
|
<div className="space-y-1.5">
|
|
<Label>Protocol</Label>
|
|
<Select value={data.protocol || '_any'} onValueChange={(v) => updateField('protocol', (v === '_any' ? '' : v) as Protocol)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_any">any</SelectItem>
|
|
{PROTOCOLS.filter(Boolean).map((p) => (
|
|
<SelectItem key={p} value={p}>
|
|
{p}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Src Address */}
|
|
<div className="space-y-1.5">
|
|
<Label>Src Address</Label>
|
|
<Input
|
|
placeholder="e.g., 192.168.1.0/24"
|
|
value={data['src-address']}
|
|
onChange={(e) => updateField('src-address', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Dst Address */}
|
|
<div className="space-y-1.5">
|
|
<Label>Dst Address</Label>
|
|
<Input
|
|
placeholder="e.g., 10.0.0.0/8"
|
|
value={data['dst-address']}
|
|
onChange={(e) => updateField('dst-address', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Src Port */}
|
|
<div className="space-y-1.5">
|
|
<Label>Src Port</Label>
|
|
<Input
|
|
placeholder="e.g., 1024-65535"
|
|
value={data['src-port']}
|
|
onChange={(e) => updateField('src-port', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Dst Port */}
|
|
<div className="space-y-1.5">
|
|
<Label>Dst Port</Label>
|
|
<Input
|
|
placeholder="e.g., 80 or 443"
|
|
value={data['dst-port']}
|
|
onChange={(e) => updateField('dst-port', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* In Interface */}
|
|
<div className="space-y-1.5">
|
|
<Label>In Interface</Label>
|
|
<Input
|
|
placeholder="e.g., ether1"
|
|
value={data['in-interface']}
|
|
onChange={(e) => updateField('in-interface', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Out Interface */}
|
|
<div className="space-y-1.5">
|
|
<Label>Out Interface</Label>
|
|
<Input
|
|
placeholder="e.g., ether2"
|
|
value={data['out-interface']}
|
|
onChange={(e) => updateField('out-interface', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Comment */}
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label>Comment</Label>
|
|
<Input
|
|
placeholder="Rule description (optional)"
|
|
value={data.comment}
|
|
onChange={(e) => updateField('comment', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Disabled */}
|
|
<div className="col-span-2 flex items-center gap-2">
|
|
<Checkbox
|
|
id="filter-disabled"
|
|
checked={data.disabled}
|
|
onCheckedChange={(checked) => updateField('disabled', checked === true)}
|
|
/>
|
|
<Label htmlFor="filter-disabled" className="cursor-pointer">
|
|
Create rule in disabled state
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={onSubmit}>
|
|
{mode === 'add' ? 'Add to Changes' : 'Update in Changes'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// NAT Rule Dialog (Visual Builder)
|
|
// ===========================================================================
|
|
|
|
interface NatRuleDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
mode: FormMode
|
|
data: NatFormData
|
|
onChange: (data: NatFormData) => void
|
|
onSubmit: () => void
|
|
}
|
|
|
|
function NatRuleDialog({
|
|
open,
|
|
onOpenChange,
|
|
mode,
|
|
data,
|
|
onChange,
|
|
onSubmit,
|
|
}: NatRuleDialogProps) {
|
|
const updateField = <K extends keyof NatFormData>(key: K, value: NatFormData[K]) => {
|
|
onChange({ ...data, [key]: value })
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{mode === 'add' ? 'Add NAT Rule' : 'Edit NAT Rule'}</DialogTitle>
|
|
<DialogDescription>
|
|
Configure NAT rule properties. Only non-empty fields will be applied.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-2 gap-4 py-2">
|
|
{/* Chain */}
|
|
<div className="space-y-1.5">
|
|
<Label>Chain *</Label>
|
|
<Select value={data.chain} onValueChange={(v) => updateField('chain', v as NatChain)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{NAT_CHAINS.map((c) => (
|
|
<SelectItem key={c} value={c}>
|
|
{c}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Action */}
|
|
<div className="space-y-1.5">
|
|
<Label>Action *</Label>
|
|
<Select value={data.action} onValueChange={(v) => updateField('action', v as NatAction)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{NAT_ACTIONS.map((a) => (
|
|
<SelectItem key={a} value={a}>
|
|
{a}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Protocol */}
|
|
<div className="space-y-1.5">
|
|
<Label>Protocol</Label>
|
|
<Select value={data.protocol || '_any'} onValueChange={(v) => updateField('protocol', (v === '_any' ? '' : v) as Protocol)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_any">any</SelectItem>
|
|
{PROTOCOLS.filter(Boolean).map((p) => (
|
|
<SelectItem key={p} value={p}>
|
|
{p}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Src Address */}
|
|
<div className="space-y-1.5">
|
|
<Label>Src Address</Label>
|
|
<Input
|
|
placeholder="e.g., 192.168.1.0/24"
|
|
value={data['src-address']}
|
|
onChange={(e) => updateField('src-address', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Dst Address */}
|
|
<div className="space-y-1.5">
|
|
<Label>Dst Address</Label>
|
|
<Input
|
|
placeholder="e.g., 10.0.0.0/8"
|
|
value={data['dst-address']}
|
|
onChange={(e) => updateField('dst-address', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Src Port */}
|
|
<div className="space-y-1.5">
|
|
<Label>Src Port</Label>
|
|
<Input
|
|
placeholder="e.g., 1024-65535"
|
|
value={data['src-port']}
|
|
onChange={(e) => updateField('src-port', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Dst Port */}
|
|
<div className="space-y-1.5">
|
|
<Label>Dst Port</Label>
|
|
<Input
|
|
placeholder="e.g., 80 or 443"
|
|
value={data['dst-port']}
|
|
onChange={(e) => updateField('dst-port', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* To Addresses */}
|
|
<div className="space-y-1.5">
|
|
<Label>To Addresses</Label>
|
|
<Input
|
|
placeholder="e.g., 192.168.1.100"
|
|
value={data['to-addresses']}
|
|
onChange={(e) => updateField('to-addresses', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* To Ports */}
|
|
<div className="space-y-1.5">
|
|
<Label>To Ports</Label>
|
|
<Input
|
|
placeholder="e.g., 8080"
|
|
value={data['to-ports']}
|
|
onChange={(e) => updateField('to-ports', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Comment */}
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label>Comment</Label>
|
|
<Input
|
|
placeholder="Rule description (optional)"
|
|
value={data.comment}
|
|
onChange={(e) => updateField('comment', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Disabled */}
|
|
<div className="col-span-2 flex items-center gap-2">
|
|
<Checkbox
|
|
id="nat-disabled"
|
|
checked={data.disabled}
|
|
onCheckedChange={(checked) => updateField('disabled', checked === true)}
|
|
/>
|
|
<Label htmlFor="nat-disabled" className="cursor-pointer">
|
|
Create rule in disabled state
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={onSubmit}>
|
|
{mode === 'add' ? 'Add to Changes' : 'Update in Changes'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Shared sub-components
|
|
// ===========================================================================
|
|
|
|
function CellEmpty() {
|
|
return <span className="text-text-muted">—</span>
|
|
}
|
|
|
|
function LoadingRows({ cols }: { cols: number }) {
|
|
return (
|
|
<>
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<tr key={i} className="border-b border-border/50">
|
|
{Array.from({ length: cols }).map((_, j) => (
|
|
<td key={j} className="px-3 py-2">
|
|
<Skeleton className="h-4 w-full" />
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</>
|
|
)
|
|
}
|