Files
the-other-dude/frontend/src/components/config/InterfacesPanel.tsx
Jason Staack b840047e19 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>
2026-03-08 19:30:44 -05:00

1393 lines
45 KiB
TypeScript

/**
* InterfacesPanel -- Guided interface configuration for RouterOS devices.
*
* Sub-tabs: Interfaces, IP Addresses, VLANs, Bridges
* Each tab provides browse data + add/edit/remove forms.
* All changes flow through useConfigPanel -> ChangePreviewModal.
*/
import { useState, useMemo, useCallback } from 'react'
import {
Network,
Globe,
Layers,
GitBranch,
Plus,
MoreHorizontal,
Pencil,
Trash2,
ToggleLeft,
ToggleRight,
} 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { SafetyToggle } from '@/components/config/SafetyToggle'
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import type { ConfigPanelProps, ConfigChange } from '@/lib/configPanelTypes'
import { cn } from '@/lib/utils'
// ---------------------------------------------------------------------------
// Sub-tab definitions
// ---------------------------------------------------------------------------
type SubTab = 'interfaces' | 'ip-addresses' | 'vlans' | 'bridges'
const SUB_TABS: { key: SubTab; label: string; icon: React.ElementType }[] = [
{ key: 'interfaces', label: 'Interfaces', icon: Network },
{ key: 'ip-addresses', label: 'IP Addresses', icon: Globe },
{ key: 'vlans', label: 'VLANs', icon: Layers },
{ key: 'bridges', label: 'Bridges', icon: GitBranch },
]
// ---------------------------------------------------------------------------
// Type badge color map
// ---------------------------------------------------------------------------
const TYPE_COLORS: Record<string, string> = {
ether: '#3B82F6',
bridge: '#8B5CF6',
vlan: '#F59E0B',
bonding: '#10B981',
pppoe: '#EF4444',
l2tp: '#EC4899',
ovpn: '#06B6D4',
wlan: '#84CC16',
}
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
const CIDR_REGEX = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/
function isValidCidr(value: string): boolean {
if (!CIDR_REGEX.test(value)) return false
const [ip, mask] = value.split('/')
const parts = ip.split('.').map(Number)
if (parts.some((p) => p < 0 || p > 255)) return false
const maskNum = Number(mask)
return maskNum >= 0 && maskNum <= 32
}
function isValidVlanId(value: number): boolean {
return Number.isInteger(value) && value >= 1 && value <= 4094
}
// ---------------------------------------------------------------------------
// InterfacesPanel
// ---------------------------------------------------------------------------
export function InterfacesPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const [subTab, setSubTab] = useState<SubTab>('interfaces')
const [previewOpen, setPreviewOpen] = useState(false)
// Shared config panel hook
const panel = useConfigPanel(tenantId, deviceId, 'interfaces')
// Browse data
const interfaces = useConfigBrowse(tenantId, deviceId, '/interface', { enabled: active })
const ipAddresses = useConfigBrowse(tenantId, deviceId, '/ip/address', { enabled: active })
const vlans = useConfigBrowse(tenantId, deviceId, '/interface/vlan', { enabled: active })
const bridges = useConfigBrowse(tenantId, deviceId, '/interface/bridge', { enabled: active })
const bridgePorts = useConfigBrowse(tenantId, deviceId, '/interface/bridge/port', {
enabled: active,
})
// Interface name list for select fields
const interfaceNames = useMemo(
() => interfaces.entries.map((e) => e.name).filter(Boolean),
[interfaces.entries],
)
const bridgeNames = useMemo(
() => bridges.entries.map((e) => e.name).filter(Boolean),
[bridges.entries],
)
const refetchAll = useCallback(() => {
interfaces.refetch()
ipAddresses.refetch()
vlans.refetch()
bridges.refetch()
bridgePorts.refetch()
}, [interfaces, ipAddresses, vlans, bridges, bridgePorts])
const handleApplyConfirm = useCallback(() => {
panel.applyChanges()
setPreviewOpen(false)
// Refetch after a short delay to allow the device to process
setTimeout(refetchAll, 1500)
}, [panel, refetchAll])
return (
<div className="space-y-4">
{/* Header: safety toggle + apply button */}
<div className="flex items-start justify-between gap-4">
<SafetyToggle mode={panel.applyMode} onModeChange={panel.setApplyMode} />
<Button
onClick={() => setPreviewOpen(true)}
disabled={panel.pendingChanges.length === 0}
className="gap-1.5 shrink-0"
>
Review & Apply
{panel.pendingChanges.length > 0 && (
<Badge className="ml-1 bg-accent/20 text-accent border-accent/40 text-xs">
{panel.pendingChanges.length}
</Badge>
)}
</Button>
</div>
{/* Sub-tab buttons */}
<div className="flex items-center gap-1">
{SUB_TABS.map(({ key, label, icon: Icon }) => (
<Button
key={key}
variant="ghost"
size="sm"
onClick={() => setSubTab(key)}
className={cn(
'gap-1.5',
subTab === key ? 'bg-elevated text-text-primary' : 'text-text-secondary',
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</Button>
))}
</div>
{/* Sub-tab content */}
{subTab === 'interfaces' && (
<InterfacesTable entries={interfaces.entries} isLoading={interfaces.isLoading} />
)}
{subTab === 'ip-addresses' && (
<IpAddressesTab
entries={ipAddresses.entries}
isLoading={ipAddresses.isLoading}
interfaceNames={interfaceNames}
addChange={panel.addChange}
/>
)}
{subTab === 'vlans' && (
<VlansTab
entries={vlans.entries}
isLoading={vlans.isLoading}
interfaceNames={interfaceNames}
addChange={panel.addChange}
/>
)}
{subTab === 'bridges' && (
<BridgesTab
bridges={bridges.entries}
bridgePorts={bridgePorts.entries}
isLoading={bridges.isLoading || bridgePorts.isLoading}
interfaceNames={interfaceNames}
bridgeNames={bridgeNames}
addChange={panel.addChange}
/>
)}
{/* Change preview modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={handleApplyConfirm}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Loading skeleton
// ---------------------------------------------------------------------------
function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="rounded-lg border border-border bg-surface">
<div className="p-3 border-b border-border">
<Skeleton className="h-4 w-32" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3 border-b border-border last:border-0">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-12" />
</div>
))}
</div>
)
}
// ---------------------------------------------------------------------------
// Interfaces Tab (read-only list)
// ---------------------------------------------------------------------------
function InterfacesTable({
entries,
isLoading,
}: {
entries: Record<string, string>[]
isLoading: boolean
}) {
if (isLoading) return <TableSkeleton />
if (entries.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
No interfaces found on this device.
</div>
)
}
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">Name</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">Type</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">Status</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">MAC</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">MTU</th>
</tr>
</thead>
<tbody>
{entries.map((entry, i) => {
const isRunning = entry.running === 'true'
const isDisabled = entry.disabled === 'true'
const ifType = entry.type || 'unknown'
return (
<tr key={entry['.id'] || i} className="border-b border-border last:border-0">
<td className="px-3 py-2 font-medium text-text-primary">{entry.name || '---'}</td>
<td className="px-3 py-2">
<Badge color={TYPE_COLORS[ifType] || null}>{ifType}</Badge>
</td>
<td className="px-3 py-2">
<span className="flex items-center gap-1.5">
<span
className={cn(
'inline-block h-2 w-2 rounded-full',
isDisabled ? 'bg-text-muted' : isRunning ? 'bg-success' : 'bg-text-muted',
)}
/>
<span className="text-text-secondary text-xs">
{isDisabled ? 'disabled' : isRunning ? 'running' : 'down'}
</span>
</span>
</td>
<td className="px-3 py-2 font-mono text-xs text-text-secondary">
{entry['mac-address'] || '---'}
</td>
<td className="px-3 py-2 text-text-secondary">{entry.mtu || '---'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// ---------------------------------------------------------------------------
// IP Addresses Tab
// ---------------------------------------------------------------------------
interface IpAddressesTabProps {
entries: Record<string, string>[]
isLoading: boolean
interfaceNames: string[]
addChange: (c: ConfigChange) => void
}
function IpAddressesTab({ entries, isLoading, interfaceNames, addChange }: IpAddressesTabProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editEntry, setEditEntry] = useState<Record<string, string> | null>(null)
const openAdd = () => {
setEditEntry(null)
setDialogOpen(true)
}
const openEdit = (entry: Record<string, string>) => {
setEditEntry(entry)
setDialogOpen(true)
}
const handleRemove = (entry: Record<string, string>) => {
addChange({
operation: 'remove',
path: '/ip/address',
entryId: entry['.id'],
properties: {},
description: `Remove IP ${entry.address} from ${entry.interface}`,
})
}
const handleToggle = (entry: Record<string, string>) => {
const newDisabled = entry.disabled === 'true' ? 'no' : 'yes'
addChange({
operation: 'set',
path: '/ip/address',
entryId: entry['.id'],
properties: { disabled: newDisabled },
description: `${newDisabled === 'yes' ? 'Disable' : 'Enable'} IP ${entry.address} on ${entry.interface}`,
})
}
if (isLoading) return <TableSkeleton />
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={openAdd} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add IP Address
</Button>
</div>
{entries.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
No IP addresses configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Address
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Network
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Interface
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Status
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-text-secondary">
Actions
</th>
</tr>
</thead>
<tbody>
{entries.map((entry, i) => {
const isDisabled = entry.disabled === 'true'
return (
<tr key={entry['.id'] || i} className="border-b border-border last:border-0">
<td className="px-3 py-2 font-mono text-text-primary">
{entry.address || '---'}
</td>
<td className="px-3 py-2 font-mono text-text-secondary">
{entry.network || '---'}
</td>
<td className="px-3 py-2 text-text-primary">{entry.interface || '---'}</td>
<td className="px-3 py-2">
<span className="flex items-center gap-1.5">
<span
className={cn(
'inline-block h-2 w-2 rounded-full',
isDisabled ? 'bg-text-muted' : 'bg-success',
)}
/>
<span className="text-text-secondary text-xs">
{isDisabled ? 'disabled' : 'active'}
</span>
</span>
</td>
<td className="px-3 py-2 text-right">
<EntryActions
entry={entry}
onEdit={openEdit}
onRemove={handleRemove}
onToggle={handleToggle}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<IpAddressDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
entry={editEntry}
interfaceNames={interfaceNames}
addChange={addChange}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// IP Address Dialog
// ---------------------------------------------------------------------------
function IpAddressDialog({
open,
onOpenChange,
entry,
interfaceNames,
addChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
entry: Record<string, string> | null
interfaceNames: string[]
addChange: (c: ConfigChange) => void
}) {
const isEdit = !!entry
const [address, setAddress] = useState('')
const [iface, setIface] = useState('')
const [disabled, setDisabled] = useState(false)
const [error, setError] = useState('')
// Reset form when dialog opens
const handleOpenChange = (val: boolean) => {
if (val) {
setAddress(entry?.address || '')
setIface(entry?.interface || '')
setDisabled(entry?.disabled === 'true')
setError('')
}
onOpenChange(val)
}
const handleSubmit = () => {
if (!isValidCidr(address)) {
setError('Invalid CIDR format. Use format like 192.168.1.1/24')
return
}
if (!iface) {
setError('Please select an interface')
return
}
setError('')
const properties: Record<string, string> = {
address,
interface: iface,
disabled: disabled ? 'yes' : 'no',
}
if (isEdit) {
addChange({
operation: 'set',
path: '/ip/address',
entryId: entry['.id'],
properties,
description: `Update IP ${address} on ${iface}`,
})
} else {
addChange({
operation: 'add',
path: '/ip/address',
properties,
description: `Add IP ${address} to ${iface}`,
})
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit IP Address' : 'Add IP Address'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Modify the IP address configuration.'
: 'Add a new IP address to an interface.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ip-address">Address (CIDR)</Label>
<Input
id="ip-address"
placeholder="192.168.1.1/24"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ip-interface">Interface</Label>
<Select value={iface} onValueChange={setIface}>
<SelectTrigger>
<SelectValue placeholder="Select interface" />
</SelectTrigger>
<SelectContent>
{interfaceNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="ip-disabled"
checked={disabled}
onCheckedChange={(v) => setDisabled(v === true)}
/>
<Label htmlFor="ip-disabled">Disabled</Label>
</div>
{error && <p className="text-xs text-error">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>{isEdit ? 'Update' : 'Add'} Change</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// VLANs Tab
// ---------------------------------------------------------------------------
interface VlansTabProps {
entries: Record<string, string>[]
isLoading: boolean
interfaceNames: string[]
addChange: (c: ConfigChange) => void
}
function VlansTab({ entries, isLoading, interfaceNames, addChange }: VlansTabProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editEntry, setEditEntry] = useState<Record<string, string> | null>(null)
const openAdd = () => {
setEditEntry(null)
setDialogOpen(true)
}
const openEdit = (entry: Record<string, string>) => {
setEditEntry(entry)
setDialogOpen(true)
}
const handleRemove = (entry: Record<string, string>) => {
addChange({
operation: 'remove',
path: '/interface/vlan',
entryId: entry['.id'],
properties: {},
description: `Remove VLAN ${entry.name} (ID: ${entry['vlan-id']})`,
})
}
const handleToggle = (entry: Record<string, string>) => {
const newDisabled = entry.disabled === 'true' ? 'no' : 'yes'
addChange({
operation: 'set',
path: '/interface/vlan',
entryId: entry['.id'],
properties: { disabled: newDisabled },
description: `${newDisabled === 'yes' ? 'Disable' : 'Enable'} VLAN ${entry.name}`,
})
}
if (isLoading) return <TableSkeleton />
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={openAdd} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add VLAN
</Button>
</div>
{entries.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
No VLANs configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Name
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
VLAN ID
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Interface
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Status
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-text-secondary">
Actions
</th>
</tr>
</thead>
<tbody>
{entries.map((entry, i) => {
const isDisabled = entry.disabled === 'true'
return (
<tr key={entry['.id'] || i} className="border-b border-border last:border-0">
<td className="px-3 py-2 font-medium text-text-primary">
{entry.name || '---'}
</td>
<td className="px-3 py-2 font-mono text-text-primary">
{entry['vlan-id'] || '---'}
</td>
<td className="px-3 py-2 text-text-primary">{entry.interface || '---'}</td>
<td className="px-3 py-2">
<span className="flex items-center gap-1.5">
<span
className={cn(
'inline-block h-2 w-2 rounded-full',
isDisabled ? 'bg-text-muted' : 'bg-success',
)}
/>
<span className="text-text-secondary text-xs">
{isDisabled ? 'disabled' : 'active'}
</span>
</span>
</td>
<td className="px-3 py-2 text-right">
<EntryActions
entry={entry}
onEdit={openEdit}
onRemove={handleRemove}
onToggle={handleToggle}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<VlanDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
entry={editEntry}
interfaceNames={interfaceNames}
addChange={addChange}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// VLAN Dialog
// ---------------------------------------------------------------------------
function VlanDialog({
open,
onOpenChange,
entry,
interfaceNames,
addChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
entry: Record<string, string> | null
interfaceNames: string[]
addChange: (c: ConfigChange) => void
}) {
const isEdit = !!entry
const [name, setName] = useState('')
const [vlanId, setVlanId] = useState('')
const [iface, setIface] = useState('')
const [disabled, setDisabled] = useState(false)
const [error, setError] = useState('')
const handleOpenChange = (val: boolean) => {
if (val) {
setName(entry?.name || '')
setVlanId(entry?.['vlan-id'] || '')
setIface(entry?.interface || '')
setDisabled(entry?.disabled === 'true')
setError('')
}
onOpenChange(val)
}
const handleSubmit = () => {
if (!name.trim()) {
setError('VLAN name is required')
return
}
const vid = Number(vlanId)
if (!isValidVlanId(vid)) {
setError('VLAN ID must be between 1 and 4094')
return
}
if (!iface) {
setError('Please select a parent interface')
return
}
setError('')
const properties: Record<string, string> = {
name: name.trim(),
'vlan-id': String(vid),
interface: iface,
disabled: disabled ? 'yes' : 'no',
}
if (isEdit) {
addChange({
operation: 'set',
path: '/interface/vlan',
entryId: entry['.id'],
properties,
description: `Update VLAN ${name} (ID: ${vid}) on ${iface}`,
})
} else {
addChange({
operation: 'add',
path: '/interface/vlan',
properties,
description: `Add VLAN ${name} (ID: ${vid}) on ${iface}`,
})
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit VLAN' : 'Add VLAN'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Modify the VLAN configuration.' : 'Create a new VLAN interface.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="vlan-name">Name</Label>
<Input
id="vlan-name"
placeholder="vlan100-mgmt"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="vlan-id">VLAN ID (1-4094)</Label>
<Input
id="vlan-id"
type="number"
min={1}
max={4094}
placeholder="100"
value={vlanId}
onChange={(e) => setVlanId(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="vlan-interface">Parent Interface</Label>
<Select value={iface} onValueChange={setIface}>
<SelectTrigger>
<SelectValue placeholder="Select interface" />
</SelectTrigger>
<SelectContent>
{interfaceNames.map((n) => (
<SelectItem key={n} value={n}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="vlan-disabled"
checked={disabled}
onCheckedChange={(v) => setDisabled(v === true)}
/>
<Label htmlFor="vlan-disabled">Disabled</Label>
</div>
{error && <p className="text-xs text-error">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>{isEdit ? 'Update' : 'Add'} Change</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// Bridges Tab
// ---------------------------------------------------------------------------
interface BridgesTabProps {
bridges: Record<string, string>[]
bridgePorts: Record<string, string>[]
isLoading: boolean
interfaceNames: string[]
bridgeNames: string[]
addChange: (c: ConfigChange) => void
}
function BridgesTab({
bridges,
bridgePorts,
isLoading,
interfaceNames,
bridgeNames,
addChange,
}: BridgesTabProps) {
const [bridgeDialogOpen, setBridgeDialogOpen] = useState(false)
const [portDialogOpen, setPortDialogOpen] = useState(false)
const [editBridge, setEditBridge] = useState<Record<string, string> | null>(null)
const [editPort, setEditPort] = useState<Record<string, string> | null>(null)
const openAddBridge = () => {
setEditBridge(null)
setBridgeDialogOpen(true)
}
const openEditBridge = (entry: Record<string, string>) => {
setEditBridge(entry)
setBridgeDialogOpen(true)
}
const handleRemoveBridge = (entry: Record<string, string>) => {
addChange({
operation: 'remove',
path: '/interface/bridge',
entryId: entry['.id'],
properties: {},
description: `Remove bridge ${entry.name}`,
})
}
const handleToggleBridge = (entry: Record<string, string>) => {
const newDisabled = entry.disabled === 'true' ? 'no' : 'yes'
addChange({
operation: 'set',
path: '/interface/bridge',
entryId: entry['.id'],
properties: { disabled: newDisabled },
description: `${newDisabled === 'yes' ? 'Disable' : 'Enable'} bridge ${entry.name}`,
})
}
const openAddPort = () => {
setEditPort(null)
setPortDialogOpen(true)
}
const openEditPort = (entry: Record<string, string>) => {
setEditPort(entry)
setPortDialogOpen(true)
}
const handleRemovePort = (entry: Record<string, string>) => {
addChange({
operation: 'remove',
path: '/interface/bridge/port',
entryId: entry['.id'],
properties: {},
description: `Remove ${entry.interface} from bridge ${entry.bridge}`,
})
}
if (isLoading) return <TableSkeleton />
return (
<div className="space-y-6">
{/* Bridges section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">Bridges</h3>
<Button variant="outline" size="sm" onClick={openAddBridge} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add Bridge
</Button>
</div>
{bridges.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-text-secondary text-sm">
No bridges configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Name
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Protocol Mode
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Status
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-text-secondary">
Actions
</th>
</tr>
</thead>
<tbody>
{bridges.map((entry, i) => {
const isDisabled = entry.disabled === 'true'
return (
<tr key={entry['.id'] || i} className="border-b border-border last:border-0">
<td className="px-3 py-2 font-medium text-text-primary">
{entry.name || '---'}
</td>
<td className="px-3 py-2 text-text-secondary">
{entry['protocol-mode'] || '---'}
</td>
<td className="px-3 py-2">
<span className="flex items-center gap-1.5">
<span
className={cn(
'inline-block h-2 w-2 rounded-full',
isDisabled ? 'bg-text-muted' : 'bg-success',
)}
/>
<span className="text-text-secondary text-xs">
{isDisabled ? 'disabled' : 'active'}
</span>
</span>
</td>
<td className="px-3 py-2 text-right">
<EntryActions
entry={entry}
onEdit={openEditBridge}
onRemove={handleRemoveBridge}
onToggle={handleToggleBridge}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
{/* Bridge Ports section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">Bridge Ports</h3>
<Button variant="outline" size="sm" onClick={openAddPort} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add Port
</Button>
</div>
{bridgePorts.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-text-secondary text-sm">
No bridge ports configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Interface
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
Bridge
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-secondary">
PVID
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-text-secondary">
Actions
</th>
</tr>
</thead>
<tbody>
{bridgePorts.map((entry, i) => (
<tr key={entry['.id'] || i} className="border-b border-border last:border-0">
<td className="px-3 py-2 font-medium text-text-primary">
{entry.interface || '---'}
</td>
<td className="px-3 py-2 text-text-primary">{entry.bridge || '---'}</td>
<td className="px-3 py-2 font-mono text-text-secondary">
{entry.pvid || '---'}
</td>
<td className="px-3 py-2 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEditPort(entry)}>
<Pencil className="h-3.5 w-3.5 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleRemovePort(entry)}
className="text-error focus:text-error"
>
<Trash2 className="h-3.5 w-3.5 mr-2" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Dialogs */}
<BridgeDialog
open={bridgeDialogOpen}
onOpenChange={setBridgeDialogOpen}
entry={editBridge}
addChange={addChange}
/>
<BridgePortDialog
open={portDialogOpen}
onOpenChange={setPortDialogOpen}
entry={editPort}
interfaceNames={interfaceNames}
bridgeNames={bridgeNames}
addChange={addChange}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Bridge Dialog
// ---------------------------------------------------------------------------
function BridgeDialog({
open,
onOpenChange,
entry,
addChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
entry: Record<string, string> | null
addChange: (c: ConfigChange) => void
}) {
const isEdit = !!entry
const [name, setName] = useState('')
const [protocolMode, setProtocolMode] = useState('rstp')
const [error, setError] = useState('')
const handleOpenChange = (val: boolean) => {
if (val) {
setName(entry?.name || '')
setProtocolMode(entry?.['protocol-mode'] || 'rstp')
setError('')
}
onOpenChange(val)
}
const handleSubmit = () => {
if (!name.trim()) {
setError('Bridge name is required')
return
}
setError('')
const properties: Record<string, string> = {
name: name.trim(),
'protocol-mode': protocolMode,
}
if (isEdit) {
addChange({
operation: 'set',
path: '/interface/bridge',
entryId: entry['.id'],
properties,
description: `Update bridge ${name} (${protocolMode})`,
})
} else {
addChange({
operation: 'add',
path: '/interface/bridge',
properties,
description: `Add bridge ${name} (${protocolMode})`,
})
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Bridge' : 'Add Bridge'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Modify the bridge configuration.' : 'Create a new bridge interface.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="bridge-name">Name</Label>
<Input
id="bridge-name"
placeholder="bridge1"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="bridge-protocol">Protocol Mode</Label>
<Select value={protocolMode} onValueChange={setProtocolMode}>
<SelectTrigger>
<SelectValue placeholder="Select protocol mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="rstp">RSTP</SelectItem>
<SelectItem value="stp">STP</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</div>
{error && <p className="text-xs text-error">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>{isEdit ? 'Update' : 'Add'} Change</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// Bridge Port Dialog
// ---------------------------------------------------------------------------
function BridgePortDialog({
open,
onOpenChange,
entry,
interfaceNames,
bridgeNames,
addChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
entry: Record<string, string> | null
interfaceNames: string[]
bridgeNames: string[]
addChange: (c: ConfigChange) => void
}) {
const isEdit = !!entry
const [iface, setIface] = useState('')
const [bridge, setBridge] = useState('')
const [pvid, setPvid] = useState('1')
const [error, setError] = useState('')
const handleOpenChange = (val: boolean) => {
if (val) {
setIface(entry?.interface || '')
setBridge(entry?.bridge || '')
setPvid(entry?.pvid || '1')
setError('')
}
onOpenChange(val)
}
const handleSubmit = () => {
if (!iface) {
setError('Please select an interface')
return
}
if (!bridge) {
setError('Please select a bridge')
return
}
setError('')
const properties: Record<string, string> = {
interface: iface,
bridge,
pvid,
}
if (isEdit) {
addChange({
operation: 'set',
path: '/interface/bridge/port',
entryId: entry['.id'],
properties,
description: `Update bridge port ${iface} on ${bridge} (PVID: ${pvid})`,
})
} else {
addChange({
operation: 'add',
path: '/interface/bridge/port',
properties,
description: `Add ${iface} to bridge ${bridge} (PVID: ${pvid})`,
})
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Bridge Port' : 'Add Bridge Port'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Modify the bridge port assignment.' : 'Assign an interface to a bridge.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="port-interface">Interface</Label>
<Select value={iface} onValueChange={setIface}>
<SelectTrigger>
<SelectValue placeholder="Select interface" />
</SelectTrigger>
<SelectContent>
{interfaceNames.map((n) => (
<SelectItem key={n} value={n}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="port-bridge">Bridge</Label>
<Select value={bridge} onValueChange={setBridge}>
<SelectTrigger>
<SelectValue placeholder="Select bridge" />
</SelectTrigger>
<SelectContent>
{bridgeNames.map((n) => (
<SelectItem key={n} value={n}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="port-pvid">PVID</Label>
<Input
id="port-pvid"
type="number"
min={1}
placeholder="1"
value={pvid}
onChange={(e) => setPvid(e.target.value)}
/>
</div>
{error && <p className="text-xs text-error">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>{isEdit ? 'Update' : 'Add'} Change</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// Shared Entry Actions Dropdown
// ---------------------------------------------------------------------------
function EntryActions({
entry,
onEdit,
onRemove,
onToggle,
}: {
entry: Record<string, string>
onEdit: (entry: Record<string, string>) => void
onRemove: (entry: Record<string, string>) => void
onToggle: (entry: Record<string, string>) => void
}) {
const isDisabled = entry.disabled === 'true'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(entry)}>
<Pencil className="h-3.5 w-3.5 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onToggle(entry)}>
{isDisabled ? (
<>
<ToggleRight className="h-3.5 w-3.5 mr-2" />
Enable
</>
) : (
<>
<ToggleLeft className="h-3.5 w-3.5 mr-2" />
Disable
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onRemove(entry)}
className="text-error focus:text-error"
>
<Trash2 className="h-3.5 w-3.5 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}