ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1393 lines
45 KiB
TypeScript
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>
|
|
)
|
|
}
|