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

ci: add GitHub Pages deployment workflow for docs site

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

View File

@@ -0,0 +1,282 @@
/**
* AddressListPanel -- Firewall address lists management.
*
* View/add/edit/delete address list entries (/ip/firewall/address-list),
* grouped by list name with collapsible sections, timeout display,
* bulk import. Standard apply mode by default.
*/
import { useState, useCallback, useMemo } from 'react'
import { Plus, Trash2, ChevronDown, ChevronRight, List, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AddressListEntry {
'.id': string
list: string
address: string
timeout: string
dynamic: string
disabled: string
comment: string
[key: string]: string
}
interface AddressListForm {
list: string
address: string
comment: string
}
const EMPTY_FORM: AddressListForm = { list: '', address: '', comment: '' }
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// AddressListPanel
// ---------------------------------------------------------------------------
export function AddressListPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const { entries, isLoading, error, refetch } = useConfigBrowse(
tenantId, deviceId, '/ip/firewall/address-list', { enabled: active },
)
const panel = useConfigPanel(tenantId, deviceId, 'address-lists')
const [previewOpen, setPreviewOpen] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [bulkOpen, setBulkOpen] = useState(false)
const [form, setForm] = useState<AddressListForm>(EMPTY_FORM)
const [bulkList, setBulkList] = useState('')
const [bulkAddresses, setBulkAddresses] = useState('')
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const typedEntries = entries as AddressListEntry[]
// Group by list name
const grouped = useMemo(() => {
const map = new Map<string, AddressListEntry[]>()
typedEntries.forEach((e) => {
const list = e.list || 'unknown'
if (!map.has(list)) map.set(list, [])
map.get(list)!.push(e)
})
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
}, [typedEntries])
const listNames = useMemo(() => grouped.map(([name]) => name), [grouped])
const toggleCollapse = useCallback((name: string) => {
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}, [])
const handleAdd = useCallback(() => {
setForm(EMPTY_FORM)
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(entry: AddressListEntry) => {
panel.addChange({
operation: 'remove', path: '/ip/firewall/address-list',
entryId: entry['.id'], properties: {},
description: `Remove ${entry.address} from list "${entry.list}"`,
})
},
[panel],
)
const handleSave = useCallback(() => {
if (!form.list || !form.address) return
const props: Record<string, string> = { list: form.list, address: form.address }
if (form.comment) props.comment = form.comment
panel.addChange({
operation: 'add', path: '/ip/firewall/address-list', properties: props,
description: `Add ${form.address} to list "${form.list}"`,
})
setDialogOpen(false)
}, [form, panel])
const handleBulkImport = useCallback(() => {
if (!bulkList || !bulkAddresses.trim()) return
const addresses = bulkAddresses.split('\n').map((a) => a.trim()).filter(Boolean)
addresses.forEach((addr) => {
panel.addChange({
operation: 'add', path: '/ip/firewall/address-list',
properties: { list: bulkList, address: addr },
description: `Add ${addr} to list "${bulkList}"`,
})
})
setBulkOpen(false)
setBulkAddresses('')
}, [bulkList, bulkAddresses, panel])
if (isLoading) {
return <div className="flex items-center justify-center py-12 text-text-secondary text-sm">Loading address lists...</div>
}
if (error) {
return <div className="flex items-center justify-center py-12 text-error text-sm">Failed to load address lists. <button className="underline ml-1" onClick={() => refetch()}>Retry</button></div>
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<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})
</Button>
</div>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<List className="h-4 w-4" />
Address Lists ({typedEntries.length} entries, {grouped.length} lists)
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="gap-1" onClick={() => setBulkOpen(true)}>
<Upload className="h-3.5 w-3.5" />
Bulk Import
</Button>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" />
Add Entry
</Button>
</div>
</div>
{grouped.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">No address list entries found.</div>
) : (
<div>
{grouped.map(([listName, listEntries]) => (
<div key={listName} className="border-b border-border/30 last:border-0">
<button
onClick={() => toggleCollapse(listName)}
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-elevated/50 transition-colors text-left"
>
{collapsed.has(listName) ? <ChevronRight className="h-3.5 w-3.5 text-text-muted" /> : <ChevronDown className="h-3.5 w-3.5 text-text-muted" />}
<span className="text-sm font-medium text-text-primary">{listName}</span>
<span className="text-xs text-text-muted">({listEntries.length})</span>
</button>
{!collapsed.has(listName) && (
<div className="px-4 pb-2">
<table className="w-full text-sm">
<tbody>
{listEntries.map((entry) => (
<tr key={entry['.id']} className="hover:bg-elevated/30 transition-colors">
<td className="py-1 pl-6 font-mono text-text-primary text-xs">{entry.address}</td>
<td className="py-1 text-text-muted text-xs">{entry.timeout || '—'}</td>
<td className="py-1">
{entry.dynamic === 'true'
? <span className="text-[10px] px-1 rounded bg-elevated text-text-muted border border-border">dynamic</span>
: <span className="text-[10px] px-1 rounded bg-success/10 text-success border border-success/40">static</span>}
</td>
<td className="py-1 text-text-muted text-xs">{entry.comment || ''}</td>
<td className="py-1 text-right">
{entry.dynamic !== 'true' && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-error hover:text-error" onClick={() => handleDelete(entry)}>
<Trash2 className="h-3 w-3" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Add single entry dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Address List Entry</DialogTitle>
<DialogDescription>Add an address to a firewall address list.</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-2">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">List Name</Label>
<Input value={form.list} onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))} placeholder="blocklist" className="h-8 text-sm" list="list-names" />
<datalist id="list-names">{listNames.map((n) => <option key={n} value={n} />)}</datalist>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Address</Label>
<Input value={form.address} onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} placeholder="192.168.1.0/24" className="h-8 text-sm font-mono" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Comment</Label>
<Input value={form.comment} onChange={(e) => setForm((f) => ({ ...f, comment: e.target.value }))} placeholder="optional" className="h-8 text-sm" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!form.list || !form.address}>Stage Entry</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk import dialog */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bulk Import Addresses</DialogTitle>
<DialogDescription>Paste one address per line to add them all to a list.</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-2">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">List Name</Label>
<Input value={bulkList} onChange={(e) => setBulkList(e.target.value)} placeholder="blocklist" className="h-8 text-sm" list="bulk-list-names" />
<datalist id="bulk-list-names">{listNames.map((n) => <option key={n} value={n} />)}</datalist>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Addresses (one per line)</Label>
<textarea
value={bulkAddresses}
onChange={(e) => setBulkAddresses(e.target.value)}
placeholder={"192.168.1.0/24\n10.0.0.0/8\n172.16.0.0/12"}
rows={8}
className="w-full rounded-md border border-border bg-elevated px-3 py-2 text-sm font-mono text-text-primary resize-y"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkOpen(false)}>Cancel</Button>
<Button onClick={handleBulkImport} disabled={!bulkList || !bulkAddresses.trim()}>
Stage {bulkAddresses.split('\n').filter((a) => a.trim()).length} Entries
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ChangePreviewModal open={previewOpen} onOpenChange={setPreviewOpen} changes={panel.pendingChanges} applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }} isApplying={panel.isApplying} />
</div>
)
}

View File

@@ -0,0 +1,505 @@
/**
* AddressPanel -- IP address management panel for device configuration.
*
* Displays addresses from /ip/address with interface selector dropdown,
* CIDR validation with network auto-calculation, add/edit/delete dialogs,
* and safe apply mode by default.
*/
import { useState, useCallback, useMemo } from 'react'
import { Plus, Pencil, Trash2, Network } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Entry & form types
// ---------------------------------------------------------------------------
interface AddressEntry {
'.id': string
address: string
network: string
broadcast: string
interface: string
'actual-interface': string
disabled: string
dynamic: string
[key: string]: string
}
interface AddressForm {
address: string
interface: string
}
const EMPTY_FORM: AddressForm = {
address: '',
interface: '',
}
// ---------------------------------------------------------------------------
// Validation & helpers
// ---------------------------------------------------------------------------
const CIDR_REGEX = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/
function validateAddressForm(form: AddressForm): Record<string, string> {
const errors: Record<string, string> = {}
if (!form.address) {
errors.address = 'Address is required'
} else if (!CIDR_REGEX.test(form.address)) {
errors.address = 'Must be valid CIDR (e.g. 192.168.1.1/24)'
}
if (!form.interface) {
errors.interface = 'Interface is required'
}
return errors
}
/**
* Calculate network address from CIDR notation.
* e.g. "192.168.1.100/24" → "192.168.1.0/24"
*/
function calculateNetwork(cidr: string): string | null {
if (!CIDR_REGEX.test(cidr)) return null
const [ipStr, maskStr] = cidr.split('/')
const mask = parseInt(maskStr, 10)
if (mask < 0 || mask > 32) return null
const octets = ipStr.split('.').map(Number)
if (octets.some((o) => o < 0 || o > 255)) return null
const ip = ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0
const maskBits = mask === 0 ? 0 : (~0 << (32 - mask)) >>> 0
const network = (ip & maskBits) >>> 0
return [
(network >>> 24) & 0xff,
(network >>> 16) & 0xff,
(network >>> 8) & 0xff,
network & 0xff,
].join('.') + '/' + mask
}
// ---------------------------------------------------------------------------
// Panel type shorthand
// ---------------------------------------------------------------------------
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// AddressPanel
// ---------------------------------------------------------------------------
export function AddressPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const { entries, isLoading, error, refetch } = useConfigBrowse(
tenantId,
deviceId,
'/ip/address',
{ enabled: active },
)
const panel = useConfigPanel(tenantId, deviceId, 'addresses')
const [previewOpen, setPreviewOpen] = useState(false)
const typedEntries = entries as AddressEntry[]
// Collect unique interface names for the selector dropdown
const interfaceNames = useMemo(() => {
const names = new Set<string>()
typedEntries.forEach((e) => {
if (e.interface) names.add(e.interface)
if (e['actual-interface']) names.add(e['actual-interface'])
})
return Array.from(names).sort()
}, [typedEntries])
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading IP addresses...
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load IP addresses.{' '}
<button className="underline ml-1" onClick={() => refetch()}>
Retry
</button>
</div>
)
}
return (
<div className="space-y-4">
{/* Header with SafetyToggle and Apply button */}
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* Address table */}
<AddressTable
entries={typedEntries}
panel={panel}
interfaceNames={interfaceNames}
/>
{/* Change Preview Modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Address Table
// ---------------------------------------------------------------------------
function AddressTable({
entries,
panel,
interfaceNames,
}: {
entries: AddressEntry[]
panel: PanelHook
interfaceNames: string[]
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<AddressEntry | null>(null)
const [form, setForm] = useState<AddressForm>(EMPTY_FORM)
const [errors, setErrors] = useState<Record<string, string>>({})
const [customInterface, setCustomInterface] = useState(false)
const calculatedNetwork = useMemo(
() => calculateNetwork(form.address),
[form.address],
)
const handleAdd = useCallback(() => {
setEditing(null)
setForm(EMPTY_FORM)
setErrors({})
setCustomInterface(false)
setDialogOpen(true)
}, [])
const handleEdit = useCallback((entry: AddressEntry) => {
setEditing(entry)
setForm({
address: entry.address || '',
interface: entry.interface || '',
})
setErrors({})
setCustomInterface(false)
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(entry: AddressEntry) => {
panel.addChange({
operation: 'remove',
path: '/ip/address',
entryId: entry['.id'],
properties: {},
description: `Remove address ${entry.address} from ${entry.interface || 'unknown'}`,
})
},
[panel],
)
const handleSave = useCallback(() => {
const validationErrors = validateAddressForm(form)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
const props: Record<string, string> = {
address: form.address,
interface: form.interface,
}
if (editing) {
panel.addChange({
operation: 'set',
path: '/ip/address',
entryId: editing['.id'],
properties: props,
description: `Edit address ${form.address} on ${form.interface}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/ip/address',
properties: props,
description: `Add address ${form.address} to ${form.interface}`,
})
}
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
{/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Network className="h-4 w-4" />
IP Addresses ({entries.length})
</div>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" />
Add Address
</Button>
</div>
{entries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
No IP addresses found.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Address</th>
<th className="text-left px-4 py-2 font-medium">Network</th>
<th className="text-left px-4 py-2 font-medium">Broadcast</th>
<th className="text-left px-4 py-2 font-medium">Interface</th>
<th className="text-left px-4 py-2 font-medium">Status</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry['.id']}
className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors"
>
<td className="px-4 py-2 font-mono text-text-primary">
{entry.address || '—'}
</td>
<td className="px-4 py-2 font-mono text-text-secondary">
{entry.network || '—'}
</td>
<td className="px-4 py-2 font-mono text-text-secondary">
{entry.broadcast || '—'}
</td>
<td className="px-4 py-2 text-text-secondary">
{entry['actual-interface'] || entry.interface || '—'}
</td>
<td className="px-4 py-2">
<AddressStatusBadge entry={entry} />
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
{entry.dynamic !== 'true' && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleEdit(entry)}
title="Edit address"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-error hover:text-error"
onClick={() => handleDelete(entry)}
title="Delete address"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Add/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? 'Edit Address' : 'Add Address'}</DialogTitle>
<DialogDescription>
{editing
? 'Modify the address properties below.'
: 'Enter the address details. Changes are staged until you apply them.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label htmlFor="address" className="text-xs text-text-secondary">
IP Address (CIDR)
</Label>
<Input
id="address"
value={form.address}
onChange={(e) =>
setForm((f) => ({ ...f, address: e.target.value }))
}
placeholder="192.168.1.1/24"
className={cn('h-8 text-sm font-mono', errors.address && 'border-error')}
/>
{errors.address && (
<p className="text-xs text-error">{errors.address}</p>
)}
{calculatedNetwork && (
<p className="text-xs text-text-muted">
Network: <span className="font-mono">{calculatedNetwork}</span>
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="interface-select" className="text-xs text-text-secondary">
Interface
</Label>
{!customInterface && interfaceNames.length > 0 ? (
<div className="flex gap-2">
<Select
value={form.interface}
onValueChange={(v) => setForm((f) => ({ ...f, interface: v }))}
>
<SelectTrigger
id="interface-select"
className={cn('h-8 text-sm flex-1', errors.interface && 'border-error')}
>
<SelectValue placeholder="Select interface..." />
</SelectTrigger>
<SelectContent>
{interfaceNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => setCustomInterface(true)}
>
Custom
</Button>
</div>
) : (
<div className="flex gap-2">
<Input
id="interface-custom"
value={form.interface}
onChange={(e) =>
setForm((f) => ({ ...f, interface: e.target.value }))
}
placeholder="ether1"
className={cn('h-8 text-sm flex-1', errors.interface && 'border-error')}
/>
{interfaceNames.length > 0 && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => setCustomInterface(false)}
>
List
</Button>
)}
</div>
)}
{errors.interface && (
<p className="text-xs text-error">{errors.interface}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
{editing ? 'Stage Edit' : 'Stage Address'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
// ---------------------------------------------------------------------------
// Status Badge
// ---------------------------------------------------------------------------
function AddressStatusBadge({ entry }: { entry: AddressEntry }) {
if (entry.disabled === 'true') {
return (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-error/10 text-error border-error/40">
disabled
</span>
)
}
if (entry.dynamic === 'true') {
return (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-info/10 text-info border-info/40">
dynamic
</span>
)
}
return (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">
active
</span>
)
}

View File

@@ -0,0 +1,454 @@
/**
* ArpPanel -- ARP table management panel for device configuration.
*
* Displays ARP entries from /ip/arp with filter tabs (All/Dynamic/Static),
* add static ARP, delete entries, flush dynamic ARP cache action,
* and standard apply mode by default.
*/
import { useState, useCallback, useMemo } from 'react'
import { Plus, Trash2, RefreshCw, Network } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { configEditorApi } from '@/lib/configEditorApi'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Filter types
// ---------------------------------------------------------------------------
type FilterTab = 'all' | 'dynamic' | 'static'
const FILTER_TABS: { key: FilterTab; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'dynamic', label: 'Dynamic' },
{ key: 'static', label: 'Static' },
]
// ---------------------------------------------------------------------------
// Entry & form types
// ---------------------------------------------------------------------------
interface ArpEntry {
'.id': string
address: string
'mac-address': string
interface: string
dynamic: string
complete: string
disabled: string
[key: string]: string
}
interface ArpForm {
address: string
'mac-address': string
interface: string
}
const EMPTY_FORM: ArpForm = {
address: '',
'mac-address': '',
interface: '',
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/
const MAC_REGEX = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/
function validateArpForm(form: ArpForm): Record<string, string> {
const errors: Record<string, string> = {}
if (!form.address) {
errors.address = 'IP address is required'
} else if (!IP_REGEX.test(form.address)) {
errors.address = 'Must be valid IP (e.g. 192.168.1.1)'
}
if (!form['mac-address']) {
errors['mac-address'] = 'MAC address is required'
} else if (!MAC_REGEX.test(form['mac-address'])) {
errors['mac-address'] = 'Must be valid MAC (e.g. AA:BB:CC:DD:EE:FF)'
}
if (!form.interface) {
errors.interface = 'Interface is required'
}
return errors
}
// ---------------------------------------------------------------------------
// Panel type shorthand
// ---------------------------------------------------------------------------
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// ArpPanel
// ---------------------------------------------------------------------------
export function ArpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const { entries, isLoading, error, refetch } = useConfigBrowse(
tenantId,
deviceId,
'/ip/arp',
{ enabled: active },
)
const panel = useConfigPanel(tenantId, deviceId, 'arp')
const [previewOpen, setPreviewOpen] = useState(false)
const [filterTab, setFilterTab] = useState<FilterTab>('all')
const typedEntries = entries as ArpEntry[]
const filteredEntries = useMemo(() => {
switch (filterTab) {
case 'dynamic':
return typedEntries.filter((e) => e.dynamic === 'true')
case 'static':
return typedEntries.filter((e) => e.dynamic !== 'true')
default:
return typedEntries
}
}, [typedEntries, filterTab])
// Flush dynamic ARP cache
const flushMutation = useMutation({
mutationFn: () =>
configEditorApi.execute(
tenantId,
deviceId,
'/ip arp remove [find dynamic=yes]',
),
onSuccess: () => {
toast.success('ARP cache flushed')
refetch()
},
onError: (err: Error) => {
toast.error('Failed to flush ARP cache', { description: err.message })
},
})
const handleFlush = useCallback(() => {
if (confirm('Flush all dynamic ARP entries? This will clear the ARP cache.')) {
flushMutation.mutate()
}
}, [flushMutation])
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading ARP table...
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load ARP table.{' '}
<button className="underline ml-1" onClick={() => refetch()}>
Retry
</button>
</div>
)
}
return (
<div className="space-y-4">
{/* Header with SafetyToggle and Apply button */}
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* Filter tabs + Flush button */}
<div className="flex items-center justify-between">
<div className="flex gap-1 p-1 rounded-lg bg-elevated">
{FILTER_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setFilterTab(tab.key)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
filterTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
)}
>
{tab.label}
</button>
))}
</div>
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={handleFlush}
disabled={flushMutation.isPending}
>
<RefreshCw className={cn('h-3.5 w-3.5', flushMutation.isPending && 'animate-spin')} />
Flush ARP
</Button>
</div>
{/* ARP table */}
<ArpTable entries={filteredEntries} panel={panel} />
{/* Change Preview Modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// ARP Table
// ---------------------------------------------------------------------------
function ArpTable({
entries,
panel,
}: {
entries: ArpEntry[]
panel: PanelHook
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [form, setForm] = useState<ArpForm>(EMPTY_FORM)
const [errors, setErrors] = useState<Record<string, string>>({})
const handleAdd = useCallback(() => {
setForm(EMPTY_FORM)
setErrors({})
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(entry: ArpEntry) => {
panel.addChange({
operation: 'remove',
path: '/ip/arp',
entryId: entry['.id'],
properties: {},
description: `Remove ARP entry ${entry.address} (${entry['mac-address']})`,
})
},
[panel],
)
const handleSave = useCallback(() => {
const validationErrors = validateArpForm(form)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
panel.addChange({
operation: 'add',
path: '/ip/arp',
properties: {
address: form.address,
'mac-address': form['mac-address'],
interface: form.interface,
},
description: `Add static ARP ${form.address}${form['mac-address']} on ${form.interface}`,
})
setDialogOpen(false)
}, [form, panel])
return (
<>
{/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Network className="h-4 w-4" />
ARP Table ({entries.length})
</div>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" />
Add Static ARP
</Button>
</div>
{entries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
No ARP entries found.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">IP Address</th>
<th className="text-left px-4 py-2 font-medium">MAC Address</th>
<th className="text-left px-4 py-2 font-medium">Interface</th>
<th className="text-left px-4 py-2 font-medium">Type</th>
<th className="text-left px-4 py-2 font-medium">Complete</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry['.id']}
className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors"
>
<td className="px-4 py-2 font-mono text-text-primary">
{entry.address || '—'}
</td>
<td className="px-4 py-2 font-mono text-text-secondary">
{entry['mac-address'] || '—'}
</td>
<td className="px-4 py-2 text-text-secondary">
{entry.interface || '—'}
</td>
<td className="px-4 py-2">
{entry.dynamic === 'true' ? (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-elevated text-text-muted border-border">
dynamic
</span>
) : (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">
static
</span>
)}
</td>
<td className="px-4 py-2">
{entry.complete === 'true' ? (
<span className="inline-block h-2 w-2 rounded-full bg-success" title="Complete" />
) : (
<span className="inline-block h-2 w-2 rounded-full bg-warning" title="Incomplete" />
)}
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
{entry.dynamic !== 'true' && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-error hover:text-error"
onClick={() => handleDelete(entry)}
title="Delete ARP entry"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Add Static ARP Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Static ARP Entry</DialogTitle>
<DialogDescription>
Create a static ARP mapping. Changes are staged until you apply them.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label htmlFor="arp-address" className="text-xs text-text-secondary">
IP Address
</Label>
<Input
id="arp-address"
value={form.address}
onChange={(e) =>
setForm((f) => ({ ...f, address: e.target.value }))
}
placeholder="192.168.1.100"
className={cn('h-8 text-sm font-mono', errors.address && 'border-error')}
/>
{errors.address && (
<p className="text-xs text-error">{errors.address}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="arp-mac" className="text-xs text-text-secondary">
MAC Address
</Label>
<Input
id="arp-mac"
value={form['mac-address']}
onChange={(e) =>
setForm((f) => ({ ...f, 'mac-address': e.target.value }))
}
placeholder="AA:BB:CC:DD:EE:FF"
className={cn('h-8 text-sm font-mono', errors['mac-address'] && 'border-error')}
/>
{errors['mac-address'] && (
<p className="text-xs text-error">{errors['mac-address']}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="arp-interface" className="text-xs text-text-secondary">
Interface
</Label>
<Input
id="arp-interface"
value={form.interface}
onChange={(e) =>
setForm((f) => ({ ...f, interface: e.target.value }))
}
placeholder="ether1"
className={cn('h-8 text-sm', errors.interface && 'border-error')}
/>
{errors.interface && (
<p className="text-xs text-error">{errors.interface}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Stage ARP Entry</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,210 @@
import { Lock, RotateCcw } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { toast } from '@/components/ui/toast'
import type { ConfigBackupEntry } from '@/lib/api'
import { cn } from '@/lib/utils'
interface BackupTimelineProps {
backups: ConfigBackupEntry[]
selectedShas: string[]
onSelectSha: (sha: string) => void
onRestore: (sha: string) => void
onCompare: (sha1: string, sha2: string) => void
}
function triggerBadgeClass(type: ConfigBackupEntry['trigger_type']) {
switch (type) {
case 'scheduled':
return 'border-info/50 bg-info/10 text-info'
case 'manual':
return 'border-success/50 bg-success/10 text-success'
case 'pre-restore':
return 'border-warning/50 bg-warning/10 text-warning'
case 'checkpoint':
return 'border-accent/50 bg-accent/10 text-accent'
case 'config-change':
return 'border-orange-500/50 bg-orange-500/10 text-orange-500'
default:
return 'border-muted bg-muted/10 text-muted-foreground'
}
}
function triggerLabel(type: ConfigBackupEntry['trigger_type']) {
switch (type) {
case 'scheduled':
return 'Scheduled'
case 'manual':
return 'Manual'
case 'pre-restore':
return 'Pre-restore'
case 'checkpoint':
return 'Checkpoint'
case 'config-change':
return 'Config Change'
default:
return type
}
}
function formatRelativeTime(isoDate: string): string {
const date = new Date(isoDate)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHr = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHr / 24)
if (diffSec < 60) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 30) return `${diffDay}d ago`
return date.toLocaleDateString()
}
function formatAbsoluteTime(isoDate: string): string {
return new Date(isoDate).toLocaleString()
}
function LineDelta({
added,
removed,
isFirst,
}: {
added: number | null
removed: number | null
isFirst: boolean
}) {
if (isFirst) {
return <span className="text-xs text-text-muted italic">Initial backup</span>
}
return (
<span className="text-xs font-mono">
{added !== null && (
<span className="text-success">+{added}</span>
)}
{added !== null && removed !== null && (
<span className="text-text-muted mx-0.5">/</span>
)}
{removed !== null && (
<span className="text-error">-{removed}</span>
)}
</span>
)
}
export function BackupTimeline({
backups,
selectedShas,
onSelectSha,
onRestore,
onCompare,
}: BackupTimelineProps) {
const canCompare = selectedShas.length === 2
if (backups.length === 0) {
return null
}
return (
<div className="space-y-2">
{canCompare && (
<div className="flex justify-end">
<button
onClick={() => onCompare(selectedShas[0], selectedShas[1])}
className="text-xs px-3 py-1 rounded border border-info/50 bg-info/10 text-info hover:bg-info/20 transition-colors"
>
Compare selected
</button>
</div>
)}
{/* Vertical timeline */}
<div className="relative">
{/* Connecting line */}
<div className="absolute left-3 top-4 bottom-4 w-px bg-elevated" />
<div className="space-y-1">
{backups.map((backup, idx) => {
const isSelected = selectedShas.includes(backup.commit_sha)
const isFirst = idx === backups.length - 1
return (
<div
key={backup.id}
className={cn(
'relative flex items-start gap-3 pl-8 pr-3 py-2.5 rounded-lg border cursor-pointer transition-colors',
isSelected
? 'border-info/50 bg-info/10'
: 'border-transparent hover:border-border hover:bg-elevated/50',
)}
onClick={() => {
if (selectedShas.length >= 2 && !selectedShas.includes(backup.commit_sha)) {
toast({ title: 'Maximum 2 backups can be selected for comparison. Deselect one first.' })
return
}
onSelectSha(backup.commit_sha)
}}
>
{/* Timeline dot */}
<div
className={cn(
'absolute left-2 top-3.5 h-2 w-2 rounded-full border',
isSelected
? 'border-info bg-accent'
: 'border-border bg-elevated',
)}
/>
{/* Content */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge className={cn('text-xs', triggerBadgeClass(backup.trigger_type))}>
{triggerLabel(backup.trigger_type)}
</Badge>
{backup.encryption_tier != null && (
<span
className="inline-flex items-center gap-0.5 text-xs text-info"
title={`Encrypted (Tier ${backup.encryption_tier})`}
>
<Lock className="h-3 w-3" />
</span>
)}
<LineDelta
added={backup.lines_added}
removed={backup.lines_removed}
isFirst={isFirst}
/>
</div>
<div className="flex items-center gap-2">
<span
className="text-xs text-text-muted"
title={formatAbsoluteTime(backup.created_at)}
>
{formatRelativeTime(backup.created_at)}
</span>
<span className="text-xs text-text-muted font-mono">
{backup.commit_sha.slice(0, 7)}
</span>
</div>
</div>
{/* Restore button */}
<button
onClick={(e) => {
e.stopPropagation()
onRestore(backup.commit_sha)
}}
className="flex-shrink-0 p-1 rounded text-text-muted hover:text-text-primary hover:bg-elevated transition-colors"
title="Restore this version"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
/**
* BandwidthTestTool -- Bandwidth test from device.
*
* Uses /tool/bandwidth-test via config editor execute.
* Direction: send, receive, both. Protocol: TCP, UDP.
* Displays throughput results.
*/
import { useState, useCallback } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Play, Square, Gauge } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { configEditorApi } from '@/lib/configEditorApi'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
interface BwResult {
direction: string
txRate: string
rxRate: string
txCurrent: string
rxCurrent: string
lostPackets: string
status: string
}
function formatBps(bps: string): string {
const val = parseInt(bps, 10)
if (isNaN(val)) return bps || '-'
if (val >= 1_000_000_000) return `${(val / 1_000_000_000).toFixed(2)} Gbps`
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)} Mbps`
if (val >= 1_000) return `${(val / 1_000).toFixed(0)} Kbps`
return `${val} bps`
}
export function BandwidthTestTool({ tenantId, deviceId }: ConfigPanelProps) {
const [target, setTarget] = useState('')
const [direction, setDirection] = useState('both')
const [protocol, setProtocol] = useState('tcp')
const [duration, setDuration] = useState('10')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [results, setResults] = useState<BwResult[]>([])
const bwMutation = useMutation({
mutationFn: async () => {
const parts = [
'/tool/bandwidth-test',
`address=${target}`,
`direction=${direction}`,
`protocol=${protocol}`,
`duration=${duration}s`,
]
if (username) parts.push(`user=${username}`)
if (password) parts.push(`password=${password}`)
return configEditorApi.execute(tenantId, deviceId, parts.join(' '))
},
onSuccess: (resp) => {
if (!resp.success) {
setResults([])
return
}
const rows: BwResult[] = resp.data.map((d) => ({
direction: d['direction'] || direction,
txRate: d['tx-total-average'] || d['tx-current'] || d['tx-10-second-average'] || '',
rxRate: d['rx-total-average'] || d['rx-current'] || d['rx-10-second-average'] || '',
txCurrent: d['tx-current'] || '',
rxCurrent: d['rx-current'] || '',
lostPackets: d['lost-packets'] || '0',
status: d['status'] || 'done',
}))
setResults(rows)
},
})
const handleRun = useCallback(() => {
if (!target.trim()) return
setResults([])
bwMutation.mutate()
}, [target, bwMutation])
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="space-y-1 col-span-2 sm:col-span-1">
<Label className="text-xs text-text-secondary">Target Address</Label>
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="192.168.1.1"
className="h-8 text-sm font-mono"
onKeyDown={(e) => e.key === 'Enter' && handleRun()}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Direction</Label>
<select
value={direction}
onChange={(e) => setDirection(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
<option value="both">Both</option>
<option value="send">Send</option>
<option value="receive">Receive</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Protocol</Label>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Duration (s)</Label>
<Input
type="number"
value={duration}
onChange={(e) => setDuration(e.target.value)}
min={1}
max={60}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Username</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="optional"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Password</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="optional"
className="h-8 text-sm"
/>
</div>
</div>
<div className="mt-3">
<Button
onClick={handleRun}
disabled={!target.trim() || bwMutation.isPending}
className="gap-1.5"
>
{bwMutation.isPending ? (
<><Square className="h-3.5 w-3.5" /> Testing...</>
) : (
<><Play className="h-3.5 w-3.5" /> Run Test</>
)}
</Button>
</div>
</div>
{bwMutation.isError && (
<div className="rounded-lg border border-error/50 bg-error/10 p-4 text-sm text-error">
Failed to execute bandwidth test. Ensure the target device has bandwidth-test server enabled.
</div>
)}
{results.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Gauge className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">Bandwidth Test Results</span>
</div>
<div className="p-4 grid grid-cols-2 sm:grid-cols-4 gap-4">
{results.map((r, i) => (
<div key={i} className="space-y-3">
{(r.txRate || r.txCurrent) && (
<div className="text-center">
<div className="text-xs text-text-muted mb-1">TX (Upload)</div>
<div className="text-xl font-bold text-accent">
{formatBps(r.txRate || r.txCurrent)}
</div>
</div>
)}
{(r.rxRate || r.rxCurrent) && (
<div className="text-center">
<div className="text-xs text-text-muted mb-1">RX (Download)</div>
<div className="text-xl font-bold text-info">
{formatBps(r.rxRate || r.rxCurrent)}
</div>
</div>
)}
{r.lostPackets && r.lostPackets !== '0' && (
<div className="text-center">
<div className="text-xs text-text-muted mb-1">Lost Packets</div>
<div className="text-sm font-medium text-error">{r.lostPackets}</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,886 @@
/**
* BatchConfigPanel -- Multi-device batch configuration wizard.
*
* Three-step workflow:
* 1. Select target devices (online only)
* 2. Define the configuration change
* 3. Review and execute sequentially with per-device status
*/
import { useState, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
CheckCircle,
XCircle,
Clock,
Loader2,
ChevronRight,
ChevronLeft,
Play,
Wifi,
Shield,
Network,
Globe,
Server,
Gauge,
} from 'lucide-react'
import { devicesApi, type DeviceResponse } from '@/lib/api'
import { configEditorApi } from '@/lib/configEditorApi'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { cn } from '@/lib/utils'
import { TableSkeleton } from '@/components/ui/page-skeleton'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type OperationType =
| 'add-firewall-rule'
| 'set-dns-servers'
| 'add-vlan'
| 'add-simple-queue'
| 'add-ip-address'
| 'add-static-dns'
interface BatchChange {
operationType: OperationType
path: string
operation: 'add' | 'set'
properties: Record<string, string>
description: string
}
type DeviceStatus = 'pending' | 'applying' | 'success' | 'failed'
interface DeviceExecState {
deviceId: string
hostname: string
ipAddress: string
status: DeviceStatus
error?: string
}
interface BatchConfigPanelProps {
tenantId: string
}
// ---------------------------------------------------------------------------
// Operation definitions
// ---------------------------------------------------------------------------
const OPERATIONS: { value: OperationType; label: string; icon: React.FC<{ className?: string }> }[] = [
{ value: 'add-firewall-rule', label: 'Add Firewall Rule', icon: Shield },
{ value: 'set-dns-servers', label: 'Set DNS Servers', icon: Globe },
{ value: 'add-vlan', label: 'Add VLAN', icon: Network },
{ value: 'add-simple-queue', label: 'Add Simple Queue', icon: Gauge },
{ value: 'add-ip-address', label: 'Add IP Address', icon: Server },
{ value: 'add-static-dns', label: 'Add Static DNS Entry', icon: Wifi },
]
// ---------------------------------------------------------------------------
// Step Indicator
// ---------------------------------------------------------------------------
function StepIndicator({ currentStep }: { currentStep: number }) {
const steps = ['Select Devices', 'Define Change', 'Review & Execute']
return (
<div className="flex items-center justify-center gap-2 mb-6">
{steps.map((label, idx) => {
const stepNum = idx + 1
const isActive = stepNum === currentStep
const isComplete = stepNum < currentStep
return (
<div key={label} className="flex items-center gap-2">
{idx > 0 && (
<div
className={cn(
'w-8 h-px',
isComplete ? 'bg-success' : 'bg-border',
)}
/>
)}
<div className="flex items-center gap-2">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium',
isActive && 'bg-accent text-white',
isComplete && 'bg-success text-white',
!isActive && !isComplete && 'bg-elevated text-text-muted',
)}
>
{isComplete ? <CheckCircle className="h-4 w-4" /> : stepNum}
</div>
<span
className={cn(
'text-sm hidden sm:inline',
isActive ? 'text-text-primary font-medium' : 'text-text-muted',
)}
>
{label}
</span>
</div>
</div>
)
})}
</div>
)
}
// ---------------------------------------------------------------------------
// Step 1: Select Devices
// ---------------------------------------------------------------------------
function DeviceSelector({
tenantId,
selectedIds,
onSelectionChange,
}: {
tenantId: string
selectedIds: Set<string>
onSelectionChange: (ids: Set<string>) => void
}) {
const { data, isLoading } = useQuery({
queryKey: ['devices', tenantId, 'batch-list'],
queryFn: () => devicesApi.list(tenantId, { page_size: 500 }),
enabled: !!tenantId,
})
const devices = data?.items ?? []
const onlineDevices = devices.filter((d) => d.status === 'online')
const toggleDevice = (id: string) => {
const next = new Set(selectedIds)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
onSelectionChange(next)
}
const selectAllOnline = () => {
onSelectionChange(new Set(onlineDevices.map((d) => d.id)))
}
const deselectAll = () => {
onSelectionChange(new Set())
}
if (isLoading) return <TableSkeleton rows={5} />
if (devices.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<p className="text-text-muted text-sm">No devices found for this tenant.</p>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-text-secondary">
{selectedIds.size} device{selectedIds.size !== 1 ? 's' : ''} selected
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={selectAllOnline}>
Select All Online ({onlineDevices.length})
</Button>
{selectedIds.size > 0 && (
<Button variant="outline" size="sm" onClick={deselectAll}>
Deselect All
</Button>
)}
</div>
</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/50">
<th className="w-10 px-3 py-2" />
<th className="text-left px-3 py-2 font-medium text-text-secondary">Hostname</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">IP Address</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">Status</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">Model</th>
</tr>
</thead>
<tbody>
{devices.map((device) => {
const isOnline = device.status === 'online'
const isSelected = selectedIds.has(device.id)
return (
<tr
key={device.id}
className={cn(
'border-b border-border/50 last:border-0 transition-colors',
isSelected && 'bg-accent/5',
isOnline ? 'cursor-pointer hover:bg-elevated/30' : 'opacity-50',
)}
onClick={() => isOnline && toggleDevice(device.id)}
>
<td className="px-3 py-2">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleDevice(device.id)}
disabled={!isOnline}
aria-label={`Select ${device.hostname}`}
/>
</td>
<td className="px-3 py-2 font-medium">{device.hostname}</td>
<td className="px-3 py-2 font-mono text-text-secondary">{device.ip_address}</td>
<td className="px-3 py-2">
<span
className={cn(
'inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded',
isOnline
? 'text-success bg-success/10'
: 'text-error bg-error/10',
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full', isOnline ? 'bg-success' : 'bg-error')} />
{device.status}
</span>
</td>
<td className="px-3 py-2 text-text-muted">{device.model ?? '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Step 2: Define Change
// ---------------------------------------------------------------------------
function ChangeDefiner({
operationType,
onOperationTypeChange,
formData,
onFormDataChange,
}: {
operationType: OperationType | null
onOperationTypeChange: (op: OperationType) => void
formData: Record<string, string>
onFormDataChange: (data: Record<string, string>) => void
}) {
const setField = (key: string, value: string) => {
onFormDataChange({ ...formData, [key]: value })
}
const field = (key: string, label: string, opts?: { placeholder?: string; type?: string }) => (
<div className="space-y-1">
<Label className="text-xs text-text-secondary">{label}</Label>
<Input
value={formData[key] ?? ''}
onChange={(e) => setField(key, e.target.value)}
placeholder={opts?.placeholder}
type={opts?.type ?? 'text'}
className="h-8 text-sm"
/>
</div>
)
return (
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Operation Type</Label>
<Select value={operationType ?? ''} onValueChange={(v) => onOperationTypeChange(v as OperationType)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select an operation..." />
</SelectTrigger>
<SelectContent>
{OPERATIONS.map((op) => {
const Icon = op.icon
return (
<SelectItem key={op.value} value={op.value}>
<div className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5" />
{op.label}
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
{operationType && (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
{operationType === 'add-firewall-rule' && (
<>
<h4 className="text-sm font-medium text-text-secondary">Firewall Rule</h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Chain</Label>
<Select value={formData.chain ?? 'input'} onValueChange={(v) => setField('chain', v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="input">input</SelectItem>
<SelectItem value="forward">forward</SelectItem>
<SelectItem value="output">output</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Action</Label>
<Select value={formData.action ?? 'accept'} onValueChange={(v) => setField('action', v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="accept">accept</SelectItem>
<SelectItem value="drop">drop</SelectItem>
<SelectItem value="reject">reject</SelectItem>
<SelectItem value="log">log</SelectItem>
</SelectContent>
</Select>
</div>
{field('src-address', 'Source Address', { placeholder: '0.0.0.0/0' })}
{field('dst-address', 'Dest Address', { placeholder: '0.0.0.0/0' })}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Protocol</Label>
<Select value={formData.protocol ?? ''} onValueChange={(v) => setField('protocol', v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">tcp</SelectItem>
<SelectItem value="udp">udp</SelectItem>
<SelectItem value="icmp">icmp</SelectItem>
</SelectContent>
</Select>
</div>
{field('dst-port', 'Dest Port', { placeholder: '80,443' })}
{field('comment', 'Comment', { placeholder: 'Batch rule' })}
</div>
</>
)}
{operationType === 'set-dns-servers' && (
<>
<h4 className="text-sm font-medium text-text-secondary">DNS Servers</h4>
{field('servers', 'DNS Servers (comma-separated)', { placeholder: '8.8.8.8,8.8.4.4' })}
<div className="flex items-center gap-2 mt-2">
<Checkbox
checked={formData['allow-remote-requests'] === 'yes'}
onCheckedChange={(checked) =>
setField('allow-remote-requests', checked ? 'yes' : 'no')
}
/>
<Label className="text-xs text-text-secondary">Allow remote requests</Label>
</div>
</>
)}
{operationType === 'add-vlan' && (
<>
<h4 className="text-sm font-medium text-text-secondary">VLAN</h4>
<div className="grid grid-cols-2 gap-3">
{field('name', 'Name', { placeholder: 'vlan100' })}
{field('vlan-id', 'VLAN ID', { placeholder: '100', type: 'number' })}
{field('interface', 'Interface', { placeholder: 'bridge1' })}
</div>
</>
)}
{operationType === 'add-simple-queue' && (
<>
<h4 className="text-sm font-medium text-text-secondary">Simple Queue</h4>
<div className="grid grid-cols-2 gap-3">
{field('name', 'Name', { placeholder: 'queue1' })}
{field('target', 'Target', { placeholder: '192.168.1.0/24' })}
{field('max-limit', 'Max Limit (upload/download)', { placeholder: '10M/10M' })}
{field('comment', 'Comment', { placeholder: 'Batch queue' })}
</div>
</>
)}
{operationType === 'add-ip-address' && (
<>
<h4 className="text-sm font-medium text-text-secondary">IP Address</h4>
<div className="grid grid-cols-2 gap-3">
{field('address', 'Address (CIDR)', { placeholder: '192.168.1.1/24' })}
{field('interface', 'Interface', { placeholder: 'ether1' })}
{field('comment', 'Comment', { placeholder: 'Batch IP' })}
</div>
</>
)}
{operationType === 'add-static-dns' && (
<>
<h4 className="text-sm font-medium text-text-secondary">Static DNS Entry</h4>
<div className="grid grid-cols-2 gap-3">
{field('name', 'Name', { placeholder: 'router.local' })}
{field('address', 'Address', { placeholder: '192.168.1.1' })}
{field('type', 'Type', { placeholder: 'A' })}
{field('comment', 'Comment', { placeholder: 'Batch DNS' })}
</div>
</>
)}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Build batch change from form data
// ---------------------------------------------------------------------------
function buildBatchChange(
operationType: OperationType,
formData: Record<string, string>,
): BatchChange | null {
const clean = (obj: Record<string, string>) => {
const result: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v && v.trim()) result[k] = v.trim()
}
return result
}
switch (operationType) {
case 'add-firewall-rule': {
const props = clean({
chain: formData.chain || 'input',
action: formData.action || 'accept',
...(formData['src-address'] ? { 'src-address': formData['src-address'] } : {}),
...(formData['dst-address'] ? { 'dst-address': formData['dst-address'] } : {}),
...(formData.protocol ? { protocol: formData.protocol } : {}),
...(formData['dst-port'] ? { 'dst-port': formData['dst-port'] } : {}),
...(formData.comment ? { comment: formData.comment } : {}),
})
return {
operationType,
path: '/ip/firewall/filter',
operation: 'add',
properties: props,
description: `Add firewall ${props.chain}/${props.action} rule`,
}
}
case 'set-dns-servers': {
if (!formData.servers?.trim()) {
toast({ title: 'DNS servers required', variant: 'destructive' })
return null
}
return {
operationType,
path: '/ip/dns',
operation: 'set',
properties: clean({
servers: formData.servers,
'allow-remote-requests': formData['allow-remote-requests'] || 'no',
}),
description: `Set DNS servers to ${formData.servers}`,
}
}
case 'add-vlan': {
const vlanId = formData['vlan-id']?.trim()
const iface = formData.interface?.trim()
if (!vlanId || !iface) {
toast({ title: 'VLAN ID and interface are required', variant: 'destructive' })
return null
}
return {
operationType,
path: '/interface/vlan',
operation: 'add',
properties: clean({
name: formData.name || `vlan${vlanId}`,
'vlan-id': vlanId,
interface: iface,
}),
description: `Add VLAN ${vlanId} on ${iface}`,
}
}
case 'add-simple-queue': {
const target = formData.target?.trim()
if (!target) {
toast({ title: 'Queue target is required', variant: 'destructive' })
return null
}
return {
operationType,
path: '/queue/simple',
operation: 'add',
properties: clean({
name: formData.name || 'batch-queue',
target,
...(formData['max-limit'] ? { 'max-limit': formData['max-limit'] } : {}),
...(formData.comment ? { comment: formData.comment } : {}),
}),
description: `Add simple queue for ${target}`,
}
}
case 'add-ip-address': {
const address = formData.address?.trim()
const iface = formData.interface?.trim()
if (!address || !iface) {
toast({ title: 'Address and interface are required', variant: 'destructive' })
return null
}
return {
operationType,
path: '/ip/address',
operation: 'add',
properties: clean({
address,
interface: iface,
...(formData.comment ? { comment: formData.comment } : {}),
}),
description: `Add IP ${address} on ${iface}`,
}
}
case 'add-static-dns': {
const name = formData.name?.trim()
const addr = formData.address?.trim()
if (!name || !addr) {
toast({ title: 'Name and address are required', variant: 'destructive' })
return null
}
return {
operationType,
path: '/ip/dns/static',
operation: 'add',
properties: clean({
name,
address: addr,
...(formData.type ? { type: formData.type } : {}),
...(formData.comment ? { comment: formData.comment } : {}),
}),
description: `Add static DNS ${name} -> ${addr}`,
}
}
}
}
// ---------------------------------------------------------------------------
// Step 3: Review & Execute
// ---------------------------------------------------------------------------
function ExecutionPanel({
change,
devices,
execStates,
isRunning,
isComplete,
onExecute,
}: {
change: BatchChange
devices: DeviceResponse[]
execStates: DeviceExecState[]
isRunning: boolean
isComplete: boolean
onExecute: () => void
}) {
const successCount = execStates.filter((s) => s.status === 'success').length
const failedCount = execStates.filter((s) => s.status === 'failed').length
return (
<div className="space-y-4">
{/* Change description */}
<div className="rounded-lg border border-border bg-surface p-4">
<h4 className="text-sm font-medium text-text-secondary mb-1">Change to Apply</h4>
<p className="text-sm text-text-primary">{change.description}</p>
<p className="text-xs text-text-muted mt-1 font-mono">
{change.path} {change.operation}{' '}
{Object.entries(change.properties)
.map(([k, v]) => `${k}=${v}`)
.join(' ')}
</p>
</div>
{/* Execute button */}
{!isRunning && !isComplete && (
<Button onClick={onExecute} className="w-full">
<Play className="h-4 w-4 mr-2" />
Execute on {devices.length} device{devices.length !== 1 ? 's' : ''}
</Button>
)}
{/* Summary */}
{isComplete && (
<div className="rounded-lg border border-border bg-surface p-4 flex items-center gap-4">
<div className="flex items-center gap-2 text-success">
<CheckCircle className="h-5 w-5" />
<span className="text-sm font-medium">{successCount} succeeded</span>
</div>
{failedCount > 0 && (
<div className="flex items-center gap-2 text-error">
<XCircle className="h-5 w-5" />
<span className="text-sm font-medium">{failedCount} failed</span>
</div>
)}
</div>
)}
{/* Device status table */}
<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/50">
<th className="text-left px-3 py-2 font-medium text-text-secondary">Hostname</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">IP Address</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">Status</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">Error</th>
</tr>
</thead>
<tbody>
{execStates.map((state) => (
<tr key={state.deviceId} className="border-b border-border/50 last:border-0">
<td className="px-3 py-2 font-medium">{state.hostname}</td>
<td className="px-3 py-2 font-mono text-text-secondary">{state.ipAddress}</td>
<td className="px-3 py-2">
<StatusIcon status={state.status} />
</td>
<td className="px-3 py-2 text-xs text-error max-w-xs truncate">
{state.error ?? ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function StatusIcon({ status }: { status: DeviceStatus }) {
switch (status) {
case 'pending':
return (
<span className="inline-flex items-center gap-1 text-xs text-text-muted">
<Clock className="h-3.5 w-3.5" /> Pending
</span>
)
case 'applying':
return (
<span className="inline-flex items-center gap-1 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Applying
</span>
)
case 'success':
return (
<span className="inline-flex items-center gap-1 text-xs text-success">
<CheckCircle className="h-3.5 w-3.5" /> Success
</span>
)
case 'failed':
return (
<span className="inline-flex items-center gap-1 text-xs text-error">
<XCircle className="h-3.5 w-3.5" /> Failed
</span>
)
}
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export function BatchConfigPanel({ tenantId }: BatchConfigPanelProps) {
const [step, setStep] = useState(1)
const [selectedDeviceIds, setSelectedDeviceIds] = useState<Set<string>>(new Set())
const [operationType, setOperationType] = useState<OperationType | null>(null)
const [formData, setFormData] = useState<Record<string, string>>({})
const [batchChange, setBatchChange] = useState<BatchChange | null>(null)
const [execStates, setExecStates] = useState<DeviceExecState[]>([])
const [isRunning, setIsRunning] = useState(false)
const [isComplete, setIsComplete] = useState(false)
// Load all devices for the execution step
const { data: deviceData } = useQuery({
queryKey: ['devices', tenantId, 'batch-list'],
queryFn: () => devicesApi.list(tenantId, { page_size: 500 }),
enabled: !!tenantId,
})
const allDevices = deviceData?.items ?? []
const selectedDevices = allDevices.filter((d) => selectedDeviceIds.has(d.id))
const handleNext = () => {
if (step === 1) {
if (selectedDeviceIds.size === 0) {
toast({ title: 'Select at least one device', variant: 'destructive' })
return
}
setStep(2)
} else if (step === 2) {
if (!operationType) {
toast({ title: 'Select an operation type', variant: 'destructive' })
return
}
const change = buildBatchChange(operationType, formData)
if (!change) return
setBatchChange(change)
// Initialize exec states
setExecStates(
selectedDevices.map((d) => ({
deviceId: d.id,
hostname: d.hostname,
ipAddress: d.ip_address,
status: 'pending' as DeviceStatus,
})),
)
setStep(3)
}
}
const handleBack = () => {
if (step === 2) setStep(1)
if (step === 3 && !isRunning) {
setStep(2)
setBatchChange(null)
setExecStates([])
setIsComplete(false)
}
}
const handleExecute = useCallback(async () => {
if (!batchChange || isRunning) return
setIsRunning(true)
for (let i = 0; i < selectedDevices.length; i++) {
const device = selectedDevices[i]
// Set to applying
setExecStates((prev) =>
prev.map((s) =>
s.deviceId === device.id ? { ...s, status: 'applying' as DeviceStatus } : s,
),
)
try {
if (batchChange.operation === 'add') {
await configEditorApi.addEntry(
tenantId,
device.id,
batchChange.path,
batchChange.properties,
)
} else {
// set operation (e.g., DNS servers)
await configEditorApi.setEntry(
tenantId,
device.id,
batchChange.path,
'',
batchChange.properties,
)
}
setExecStates((prev) =>
prev.map((s) =>
s.deviceId === device.id ? { ...s, status: 'success' as DeviceStatus } : s,
),
)
} catch (err) {
const errorMsg =
err instanceof Error ? err.message : 'Unknown error'
setExecStates((prev) =>
prev.map((s) =>
s.deviceId === device.id
? { ...s, status: 'failed' as DeviceStatus, error: errorMsg }
: s,
),
)
}
}
setIsRunning(false)
setIsComplete(true)
toast({ title: 'Batch execution complete' })
}, [batchChange, isRunning, selectedDevices, tenantId])
const handleReset = () => {
setStep(1)
setSelectedDeviceIds(new Set())
setOperationType(null)
setFormData({})
setBatchChange(null)
setExecStates([])
setIsRunning(false)
setIsComplete(false)
}
return (
<div className="space-y-4">
<StepIndicator currentStep={step} />
{/* Step content */}
{step === 1 && (
<DeviceSelector
tenantId={tenantId}
selectedIds={selectedDeviceIds}
onSelectionChange={setSelectedDeviceIds}
/>
)}
{step === 2 && (
<ChangeDefiner
operationType={operationType}
onOperationTypeChange={setOperationType}
formData={formData}
onFormDataChange={setFormData}
/>
)}
{step === 3 && batchChange && (
<ExecutionPanel
change={batchChange}
devices={selectedDevices}
execStates={execStates}
isRunning={isRunning}
isComplete={isComplete}
onExecute={handleExecute}
/>
)}
{/* Navigation buttons */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<div>
{step > 1 && !isRunning && (
<Button variant="outline" onClick={handleBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
Back
</Button>
)}
</div>
<div className="flex gap-2">
{isComplete && (
<Button variant="outline" onClick={handleReset}>
Start New Batch
</Button>
)}
{step < 3 && (
<Button onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,321 @@
/**
* BridgePortPanel -- Bridge port management.
*
* View/add/edit/delete bridge ports (/interface/bridge/port).
* Columns: interface, bridge, PVID, frame-types, ingress-filtering.
* STP settings: path-cost, priority, edge port.
* Hardware offload indicator.
*/
import { useState, useCallback, useMemo } from 'react'
import { Plus, Pencil, Trash2, Cpu } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
const FRAME_TYPES = ['admit-all', 'admit-only-untagged-and-priority-tagged', 'admit-only-vlan-tagged']
export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const ports = useConfigBrowse(tenantId, deviceId, '/interface/bridge/port', { enabled: active })
const bridges = useConfigBrowse(tenantId, deviceId, '/interface/bridge', { enabled: active })
const interfaces = useConfigBrowse(tenantId, deviceId, '/interface', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'bridge-ports')
const [previewOpen, setPreviewOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [editEntry, setEditEntry] = useState<Record<string, string> | null>(null)
const [formData, setFormData] = useState<Record<string, string>>({})
const bridgeNames = useMemo(
() => bridges.entries.map((b) => b['name']).filter(Boolean),
[bridges.entries],
)
const ifaceNames = useMemo(
() => interfaces.entries.map((i) => i['name']).filter(Boolean),
[interfaces.entries],
)
const handleAdd = useCallback(() => {
setEditEntry(null)
setFormData({
interface: '',
bridge: bridgeNames[0] || 'bridge1',
pvid: '1',
'frame-types': 'admit-all',
'ingress-filtering': 'no',
'path-cost': '10',
priority: '0x80',
edge: 'auto',
'hw': 'yes',
})
setEditOpen(true)
}, [bridgeNames])
const handleEdit = useCallback((entry: Record<string, string>) => {
setEditEntry(entry)
setFormData({
interface: entry['interface'] || '',
bridge: entry['bridge'] || '',
pvid: entry['pvid'] || '1',
'frame-types': entry['frame-types'] || 'admit-all',
'ingress-filtering': entry['ingress-filtering'] || 'no',
'path-cost': entry['path-cost'] || '10',
priority: entry['priority'] || '0x80',
edge: entry['edge'] || 'auto',
'hw': entry['hw'] || 'yes',
})
setEditOpen(true)
}, [])
const handleSave = useCallback(() => {
if (!formData['interface'] || !formData['bridge']) return
if (editEntry) {
const props: Record<string, string> = {}
Object.entries(formData).forEach(([key, value]) => {
if (value && value !== editEntry[key]) props[key] = value
})
if (Object.keys(props).length === 0) { setEditOpen(false); return }
panel.addChange({
operation: 'set',
path: '/interface/bridge/port',
entryId: editEntry['.id'],
properties: props,
description: `Update bridge port ${formData['interface']} on ${formData['bridge']}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/interface/bridge/port',
properties: formData,
description: `Add ${formData['interface']} to bridge ${formData['bridge']}`,
})
}
setEditOpen(false)
}, [formData, editEntry, panel])
const handleDelete = useCallback((entry: Record<string, string>) => {
panel.addChange({
operation: 'remove',
path: '/interface/bridge/port',
entryId: entry['.id'],
properties: {},
description: `Remove ${entry['interface']} from bridge ${entry['bridge']}`,
})
}, [panel])
if (ports.isLoading) {
return <div className="flex items-center justify-center py-12 text-text-secondary text-sm">Loading bridge ports...</div>
}
if (ports.error) {
return <div className="flex items-center justify-center py-12 text-error text-sm">Failed to load. <button className="underline ml-1" onClick={() => ports.refetch()}>Retry</button></div>
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<SafetyToggle mode={panel.applyMode} onModeChange={panel.setApplyMode} />
<div className="flex gap-2">
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" /> Add Port
</Button>
<Button size="sm" disabled={panel.pendingChanges.length === 0 || panel.isApplying} onClick={() => setPreviewOpen(true)}>
Review & Apply ({panel.pendingChanges.length})
</Button>
</div>
</div>
{ports.entries.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
No bridge ports configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 text-text-muted">
<th className="text-left px-3 py-2">Interface</th>
<th className="text-left px-3 py-2">Bridge</th>
<th className="text-left px-3 py-2">PVID</th>
<th className="text-left px-3 py-2">Frame Types</th>
<th className="text-left px-3 py-2">Ingress Filter</th>
<th className="text-left px-3 py-2">STP</th>
<th className="text-center px-3 py-2">HW</th>
<th className="text-right px-3 py-2">Actions</th>
</tr>
</thead>
<tbody className="font-mono">
{ports.entries.map((entry) => (
<tr key={entry['.id']} className="border-b border-border/20 last:border-0">
<td className="px-3 py-1.5 text-text-primary font-medium">{entry['interface']}</td>
<td className="px-3 py-1.5 text-text-secondary">{entry['bridge']}</td>
<td className="px-3 py-1.5 text-text-primary">{entry['pvid'] || '1'}</td>
<td className="px-3 py-1.5 text-text-secondary text-[10px]">{entry['frame-types'] || 'admit-all'}</td>
<td className="px-3 py-1.5">
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded',
entry['ingress-filtering'] === 'yes'
? 'bg-success/10 text-success'
: 'bg-warning/10 text-warning',
)}>
{entry['ingress-filtering'] || 'no'}
</span>
</td>
<td className="px-3 py-1.5 text-text-muted text-[10px]">
cost={entry['path-cost'] || '10'} edge={entry['edge'] || 'auto'}
</td>
<td className="px-3 py-1.5 text-center">
{entry['hw'] === 'yes' && <Cpu className="h-3.5 w-3.5 text-success inline-block" title="Hardware offload" />}
</td>
<td className="px-3 py-1.5 text-right">
<div className="flex gap-1 justify-end">
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => handleEdit(entry)}>
<Pencil className="h-3 w-3" />
</Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-error" onClick={() => handleDelete(entry)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Edit/Add dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editEntry ? 'Edit Bridge Port' : 'Add Bridge Port'}</DialogTitle>
<DialogDescription>Configure bridge port settings. Changes are staged.</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Interface</Label>
<select
value={formData['interface'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, interface: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
>
<option value="">Select...</option>
{ifaceNames.map((name) => <option key={name} value={name}>{name}</option>)}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Bridge</Label>
<select
value={formData['bridge'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, bridge: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
>
{bridgeNames.map((name) => <option key={name} value={name}>{name}</option>)}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">PVID</Label>
<Input
type="number"
value={formData['pvid'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, pvid: e.target.value }))}
min={1} max={4094}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Frame Types</Label>
<select
value={formData['frame-types'] || 'admit-all'}
onChange={(e) => setFormData((f) => ({ ...f, 'frame-types': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
{FRAME_TYPES.map((ft) => <option key={ft} value={ft}>{ft}</option>)}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Ingress Filtering</Label>
<select
value={formData['ingress-filtering'] || 'no'}
onChange={(e) => setFormData((f) => ({ ...f, 'ingress-filtering': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">HW Offload</Label>
<select
value={formData['hw'] || 'yes'}
onChange={(e) => setFormData((f) => ({ ...f, hw: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
</div>
<div className="border-t border-border pt-3">
<span className="text-xs font-medium text-text-secondary">STP Settings</span>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Path Cost</Label>
<Input
type="number"
value={formData['path-cost'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, 'path-cost': e.target.value }))}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Priority</Label>
<Input
value={formData['priority'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, priority: e.target.value }))}
className="h-8 text-sm font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Edge</Label>
<select
value={formData['edge'] || 'auto'}
onChange={(e) => setFormData((f) => ({ ...f, edge: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
<option value="auto">Auto</option>
<option value="yes">Yes</option>
<option value="no">No</option>
<option value="no-discover">No Discover</option>
</select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!formData['interface'] || !formData['bridge']}>Stage Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ChangePreviewModal open={previewOpen} onOpenChange={setPreviewOpen} changes={panel.pendingChanges} applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }} isApplying={panel.isApplying} />
</div>
)
}

View File

@@ -0,0 +1,304 @@
/**
* BridgeVlanPanel -- Bridge VLAN table management.
*
* View/add/edit/delete VLAN entries (/interface/bridge/vlan).
* Tagged/untagged port assignment per VLAN.
* Bridge VLAN filtering enable/disable.
* Visual port-to-VLAN matrix view.
*/
import { useState, useCallback, useMemo } from 'react'
import { Plus, Pencil, Trash2, Network } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
export function BridgeVlanPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const vlans = useConfigBrowse(tenantId, deviceId, '/interface/bridge/vlan', { enabled: active })
const bridges = useConfigBrowse(tenantId, deviceId, '/interface/bridge', { enabled: active })
const ports = useConfigBrowse(tenantId, deviceId, '/interface/bridge/port', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'bridge-vlans')
const [previewOpen, setPreviewOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [editEntry, setEditEntry] = useState<Record<string, string> | null>(null)
const [formData, setFormData] = useState<Record<string, string>>({})
const bridgeNames = useMemo(
() => bridges.entries.map((b) => b['name']).filter(Boolean),
[bridges.entries],
)
// Get all port interfaces on bridges for the matrix
const portInterfaces = useMemo(
() => ports.entries.map((p) => p['interface']).filter(Boolean),
[ports.entries],
)
// Check if VLAN filtering is enabled on any bridge
const vlanFilteringBridges = useMemo(
() => bridges.entries.filter((b) => b['vlan-filtering'] === 'true' || b['vlan-filtering'] === 'yes'),
[bridges.entries],
)
const handleAdd = useCallback(() => {
setEditEntry(null)
setFormData({
bridge: bridgeNames[0] || 'bridge1',
'vlan-ids': '',
tagged: '',
untagged: '',
})
setEditOpen(true)
}, [bridgeNames])
const handleEdit = useCallback((entry: Record<string, string>) => {
setEditEntry(entry)
setFormData({
bridge: entry['bridge'] || '',
'vlan-ids': entry['vlan-ids'] || '',
tagged: entry['tagged'] || '',
untagged: entry['untagged'] || '',
})
setEditOpen(true)
}, [])
const handleSave = useCallback(() => {
if (!formData['vlan-ids'] || !formData['bridge']) return
if (editEntry) {
const props: Record<string, string> = {}
Object.entries(formData).forEach(([key, value]) => {
if (value !== editEntry[key]) props[key] = value
})
if (Object.keys(props).length === 0) { setEditOpen(false); return }
panel.addChange({
operation: 'set',
path: '/interface/bridge/vlan',
entryId: editEntry['.id'],
properties: props,
description: `Update VLAN ${formData['vlan-ids']} on ${formData['bridge']}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/interface/bridge/vlan',
properties: formData,
description: `Add VLAN ${formData['vlan-ids']} to ${formData['bridge']}`,
})
}
setEditOpen(false)
}, [formData, editEntry, panel])
const handleDelete = useCallback((entry: Record<string, string>) => {
panel.addChange({
operation: 'remove',
path: '/interface/bridge/vlan',
entryId: entry['.id'],
properties: {},
description: `Remove VLAN ${entry['vlan-ids']} from ${entry['bridge']}`,
})
}, [panel])
const handleToggleVlanFiltering = useCallback((bridge: Record<string, string>) => {
const current = bridge['vlan-filtering'] === 'true' || bridge['vlan-filtering'] === 'yes'
panel.addChange({
operation: 'set',
path: '/interface/bridge',
entryId: bridge['.id'],
properties: { 'vlan-filtering': current ? 'no' : 'yes' },
description: `${current ? 'Disable' : 'Enable'} VLAN filtering on ${bridge['name']}`,
})
}, [panel])
if (vlans.isLoading) {
return <div className="flex items-center justify-center py-12 text-text-secondary text-sm">Loading bridge VLANs...</div>
}
if (vlans.error) {
return <div className="flex items-center justify-center py-12 text-error text-sm">Failed to load. <button className="underline ml-1" onClick={() => vlans.refetch()}>Retry</button></div>
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<SafetyToggle mode={panel.applyMode} onModeChange={panel.setApplyMode} />
<div className="flex gap-2">
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" /> Add VLAN
</Button>
<Button size="sm" disabled={panel.pendingChanges.length === 0 || panel.isApplying} onClick={() => setPreviewOpen(true)}>
Review & Apply ({panel.pendingChanges.length})
</Button>
</div>
</div>
{/* VLAN Filtering status per bridge */}
{bridges.entries.length > 0 && (
<div className="rounded-lg border border-border bg-surface p-3">
<div className="flex items-center gap-2 mb-2">
<Network className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">Bridge VLAN Filtering</span>
</div>
<div className="flex flex-wrap gap-2">
{bridges.entries.map((bridge) => {
const enabled = bridge['vlan-filtering'] === 'true' || bridge['vlan-filtering'] === 'yes'
return (
<button
key={bridge['.id']}
onClick={() => handleToggleVlanFiltering(bridge)}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1 rounded border text-xs transition-colors',
enabled
? 'border-success/50 bg-success/10 text-success'
: 'border-border bg-elevated text-text-muted hover:text-text-secondary',
)}
>
<span className={cn('h-2 w-2 rounded-full', enabled ? 'bg-success' : 'bg-text-muted')} />
{bridge['name']}: {enabled ? 'Enabled' : 'Disabled'}
</button>
)
})}
</div>
</div>
)}
{/* VLAN Table */}
{vlans.entries.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
No bridge VLAN entries configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 text-text-muted">
<th className="text-left px-3 py-2">VLAN IDs</th>
<th className="text-left px-3 py-2">Bridge</th>
<th className="text-left px-3 py-2">Tagged</th>
<th className="text-left px-3 py-2">Untagged</th>
<th className="text-left px-3 py-2">Current Tagged</th>
<th className="text-left px-3 py-2">Current Untagged</th>
<th className="text-right px-3 py-2">Actions</th>
</tr>
</thead>
<tbody className="font-mono">
{vlans.entries.map((entry) => (
<tr key={entry['.id']} className="border-b border-border/20 last:border-0">
<td className="px-3 py-1.5">
<span className="text-accent font-medium">{entry['vlan-ids']}</span>
</td>
<td className="px-3 py-1.5 text-text-secondary">{entry['bridge']}</td>
<td className="px-3 py-1.5 text-text-primary">
{entry['tagged'] ? (
<div className="flex flex-wrap gap-1">
{entry['tagged'].split(',').map((p) => (
<span key={p} className="bg-info/10 text-info px-1 py-0.5 rounded text-[10px]">{p.trim()}</span>
))}
</div>
) : '-'}
</td>
<td className="px-3 py-1.5 text-text-primary">
{entry['untagged'] ? (
<div className="flex flex-wrap gap-1">
{entry['untagged'].split(',').map((p) => (
<span key={p} className="bg-warning/10 text-warning px-1 py-0.5 rounded text-[10px]">{p.trim()}</span>
))}
</div>
) : '-'}
</td>
<td className="px-3 py-1.5 text-text-muted text-[10px]">{entry['current-tagged'] || '-'}</td>
<td className="px-3 py-1.5 text-text-muted text-[10px]">{entry['current-untagged'] || '-'}</td>
<td className="px-3 py-1.5 text-right">
<div className="flex gap-1 justify-end">
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => handleEdit(entry)}>
<Pencil className="h-3 w-3" />
</Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-error" onClick={() => handleDelete(entry)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Edit/Add dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editEntry ? 'Edit Bridge VLAN' : 'Add Bridge VLAN'}</DialogTitle>
<DialogDescription>Configure VLAN entry. Comma-separate multiple ports. Changes are staged.</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Bridge</Label>
<select
value={formData['bridge'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, bridge: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
>
{bridgeNames.map((name) => <option key={name} value={name}>{name}</option>)}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">VLAN IDs</Label>
<Input
value={formData['vlan-ids'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, 'vlan-ids': e.target.value }))}
placeholder="10,20 or 10-20"
className="h-8 text-sm font-mono"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Tagged Ports</Label>
<Input
value={formData['tagged'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, tagged: e.target.value }))}
placeholder="bridge1,ether1,ether2"
className="h-8 text-sm font-mono"
/>
{portInterfaces.length > 0 && (
<p className="text-[10px] text-text-muted">Available: {portInterfaces.join(', ')}</p>
)}
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Untagged Ports</Label>
<Input
value={formData['untagged'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, untagged: e.target.value }))}
placeholder="ether3,ether4"
className="h-8 text-sm font-mono"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!formData['vlan-ids'] || !formData['bridge']}>Stage Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ChangePreviewModal open={previewOpen} onOpenChange={setPreviewOpen} changes={panel.pendingChanges} applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }} isApplying={panel.isApplying} />
</div>
)
}

View File

@@ -0,0 +1,133 @@
/**
* ChangePreviewModal — Preview pending config changes before execution.
*
* Standard Apply mode: numbered list of human-readable change descriptions.
* Safe Apply (with auto-revert) mode: generated RSC script in a monospace code block.
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { AlertTriangle, Zap, ShieldCheck } from 'lucide-react'
import type { ApplyMode, ConfigChange } from '@/lib/configPanelTypes'
import { describeChanges, generateRscScript } from '@/lib/configPanelTypes'
interface ChangePreviewModalProps {
open: boolean
onOpenChange: (open: boolean) => void
changes: ConfigChange[]
applyMode: ApplyMode
onConfirm: () => void
isApplying: boolean
}
const WARNINGS: Record<ApplyMode, string> = {
quick: 'These changes will be applied immediately without rollback capability.',
safe: 'This RSC script will be executed on the device.',
}
export function ChangePreviewModal({
open,
onOpenChange,
changes,
applyMode,
onConfirm,
isApplying,
}: ChangePreviewModalProps) {
const changeCount = changes.length
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Review Changes
{applyMode === 'quick' ? (
<Badge className="gap-1 bg-accent/20 text-accent border-accent/40">
<Zap className="h-3 w-3" />
Standard
</Badge>
) : (
<Badge className="gap-1 bg-accent/20 text-accent border-accent/40">
<ShieldCheck className="h-3 w-3" />
Safe (auto-revert)
</Badge>
)}
</DialogTitle>
<DialogDescription>
{changeCount} change{changeCount !== 1 ? 's' : ''} pending
</DialogDescription>
</DialogHeader>
{/* Warning banner */}
<div className="flex items-start gap-2 bg-warning/10 text-warning border border-warning/30 rounded-lg p-3 text-sm">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{WARNINGS[applyMode]}</span>
</div>
{/* Change details */}
<div className="max-h-72 overflow-y-auto">
{applyMode === 'quick' ? (
<QuickPreview changes={changes} />
) : (
<SafePreview changes={changes} />
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isApplying}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isApplying || changeCount === 0}
>
{isApplying
? 'Applying...'
: `Apply ${changeCount} Change${changeCount !== 1 ? 's' : ''}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function QuickPreview({ changes }: { changes: ConfigChange[] }) {
const descriptions = describeChanges(changes)
return (
<ol className="space-y-1.5 text-sm text-text-primary">
{descriptions.map((desc, i) => (
<li key={i} className="flex gap-2">
<span className="text-text-secondary font-mono shrink-0 w-5 text-right">
{i + 1}.
</span>
<span>{changes[i].description}</span>
</li>
))}
</ol>
)
}
function SafePreview({ changes }: { changes: ConfigChange[] }) {
const script = generateRscScript(changes)
return (
<pre className="font-mono text-sm bg-elevated rounded-lg p-4 overflow-x-auto text-text-primary whitespace-pre-wrap break-all">
{script}
</pre>
)
}

View File

@@ -0,0 +1,149 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { DiffView, DiffModeEnum, DiffFile } from '@git-diff-view/react'
import { highlighter } from '@git-diff-view/lowlight'
import '@git-diff-view/react/styles/diff-view.css'
import { Lock } from 'lucide-react'
import { configApi } from '@/lib/api'
interface ConfigDiffViewerProps {
tenantId: string
deviceId: string
oldSha: string
newSha: string
oldDate: string
newDate: string
/** Whether either backup being compared is encrypted */
isEncrypted?: boolean
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString()
}
/**
* Config diff viewer with client-side diff computation.
*
* For encrypted backups (Tier 2), the server decrypts Transit ciphertext
* and returns plaintext. The diff is computed entirely in the browser
* by @git-diff-view/react (client-side DiffFile instance).
*
* For future Tier 1 (client-side encrypted) backups, the decryptForDiff
* utility from @/lib/diffUtils will decrypt with the vault key before
* passing text to the diff viewer.
*/
export function ConfigDiffViewer({
tenantId,
deviceId,
oldSha,
newSha,
oldDate,
newDate,
isEncrypted = false,
}: ConfigDiffViewerProps) {
const [mode, setMode] = useState<DiffModeEnum>(DiffModeEnum.Split)
const { data: oldText, isLoading: loadingOld } = useQuery({
queryKey: ['config-export', tenantId, deviceId, oldSha],
queryFn: () => configApi.getExportText(tenantId, deviceId, oldSha),
})
const { data: newText, isLoading: loadingNew } = useQuery({
queryKey: ['config-export', tenantId, deviceId, newSha],
queryFn: () => configApi.getExportText(tenantId, deviceId, newSha),
})
const diffFile = useMemo(() => {
if (!oldText || !newText) return null
const file = DiffFile.createInstance({
oldFile: {
fileName: 'export.rsc',
fileLang: 'routeros',
content: oldText,
},
newFile: {
fileName: 'export.rsc',
fileLang: 'routeros',
content: newText,
},
hunks: [],
})
file.initTheme('dark')
file.init()
file.buildSplitDiffLines()
file.buildUnifiedDiffLines()
return file
}, [oldText, newText])
const isLoading = loadingOld || loadingNew
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-surface">
<div className="flex items-center gap-2 text-xs text-text-muted">
{isEncrypted && (
<span className="inline-flex items-center gap-1 text-info" title="Decrypted from encrypted backup">
<Lock className="h-3 w-3" />
</span>
)}
<span className="font-mono text-text-muted">{oldSha.slice(0, 7)}</span>
{' '}
<span className="text-text-muted">{formatDate(oldDate)}</span>
{' '}
<span className="text-text-muted mx-1">&rarr;</span>
{' '}
<span className="font-mono text-text-muted">{newSha.slice(0, 7)}</span>
{' '}
<span className="text-text-muted">{formatDate(newDate)}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setMode(DiffModeEnum.Split)}
className={`text-xs px-2 py-0.5 rounded transition-colors ${
mode === DiffModeEnum.Split
? 'bg-border text-text-primary'
: 'text-text-muted hover:text-text-primary/70'
}`}
>
Split
</button>
<button
onClick={() => setMode(DiffModeEnum.Unified)}
className={`text-xs px-2 py-0.5 rounded transition-colors ${
mode === DiffModeEnum.Unified
? 'bg-border text-text-primary'
: 'text-text-muted hover:text-text-primary/70'
}`}
>
Unified
</button>
</div>
</div>
{/* Diff content */}
{isLoading ? (
<div className="p-8 text-center text-sm text-text-muted animate-pulse">
Loading diff...
</div>
) : !diffFile ? (
<div className="p-8 text-center text-sm text-text-muted">
Unable to load backup contents.
</div>
) : (
<div className="diff-view-wrapper overflow-auto max-h-[600px]">
<DiffView
diffFile={diffFile}
diffViewMode={mode}
diffViewTheme="dark"
diffViewHighlight
registerHighlighter={highlighter}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,232 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Download, Flag, RefreshCw } from 'lucide-react'
import { configApi } from '@/lib/api'
import { toast } from '@/components/ui/toast'
import { Button } from '@/components/ui/button'
import { BackupTimeline } from './BackupTimeline'
import { RestoreButton } from './RestoreButton'
// Lazy import to avoid loading the diff view CSS until it's needed
import { ConfigDiffViewer } from './ConfigDiffViewer'
interface ConfigTabProps {
tenantId: string
deviceId: string
deviceHostname: string
active?: boolean
}
export function ConfigTab({
tenantId,
deviceId,
deviceHostname,
active = true,
}: ConfigTabProps) {
const queryClient = useQueryClient()
const [selectedShas, setSelectedShas] = useState<string[]>([])
const [diffShas, setDiffShas] = useState<[string, string] | null>(null)
// Restore dialog state
const [restoreSha, setRestoreSha] = useState<string | null>(null)
const [restoreOpen, setRestoreOpen] = useState(false)
const { data: backups, isLoading } = useQuery({
queryKey: ['config-backups', tenantId, deviceId],
queryFn: () => configApi.listBackups(tenantId, deviceId),
enabled: active,
})
const triggerMutation = useMutation({
mutationFn: () => configApi.triggerBackup(tenantId, deviceId),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['config-backups', tenantId, deviceId],
})
toast({ title: 'Backup created', description: 'Config backup completed successfully.' })
},
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Backup failed'
toast({ title: 'Backup failed', description: message, variant: 'destructive' })
},
})
const checkpointMutation = useMutation({
mutationFn: () => configApi.createCheckpoint(tenantId, deviceId),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['config-backups', tenantId, deviceId],
})
toast({ title: 'Checkpoint created', description: 'Restore point saved successfully.' })
},
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Failed to create checkpoint'
toast({ title: 'Checkpoint failed', description: message, variant: 'destructive' })
},
})
const handleSelectSha = (sha: string) => {
setSelectedShas((prev) => {
if (prev.includes(sha)) {
return prev.filter((s) => s !== sha)
}
if (prev.length >= 2) {
// Replace the oldest selection
return [prev[1], sha]
}
return [...prev, sha]
})
// Clear diff view when selection changes
setDiffShas(null)
}
const handleCompare = (sha1: string, sha2: string) => {
setDiffShas([sha1, sha2])
}
const handleRestore = (sha: string) => {
setRestoreSha(sha)
setRestoreOpen(true)
}
const handleRestoreComplete = () => {
void queryClient.invalidateQueries({
queryKey: ['config-backups', tenantId, deviceId],
})
setSelectedShas([])
setDiffShas(null)
}
// Find backup entry for restore dialog
const restoreEntry = restoreSha
? backups?.find((b) => b.commit_sha === restoreSha)
: null
return (
<div className="mt-4 space-y-4">
{/* Header bar */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-secondary">Config Backups</h3>
<div className="flex items-center gap-2">
{selectedShas.length > 0 && (
<span className="text-xs text-text-muted">
{selectedShas.length} selected
</span>
)}
<Button
variant="outline"
size="sm"
onClick={() => checkpointMutation.mutate()}
disabled={checkpointMutation.isPending}
title="Save a restore point before making changes"
>
{checkpointMutation.isPending ? (
<>
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
Creating...
</>
) : (
<>
<Flag className="h-3.5 w-3.5" />
Checkpoint
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => triggerMutation.mutate()}
disabled={triggerMutation.isPending}
>
{triggerMutation.isPending ? (
<>
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
Backing up...
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Backup Now
</>
)}
</Button>
</div>
</div>
{/* Main layout: timeline left, diff right */}
<div className="flex gap-4">
{/* Timeline panel */}
<div className="w-72 flex-shrink-0">
{isLoading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-14 rounded-lg border border-border bg-surface animate-pulse"
/>
))}
</div>
) : !backups || backups.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
No backups yet. Click &lsquo;Backup Now&rsquo; to create the first backup.
</div>
) : (
<BackupTimeline
backups={backups}
selectedShas={selectedShas}
onSelectSha={handleSelectSha}
onRestore={handleRestore}
onCompare={handleCompare}
/>
)}
</div>
{/* Diff panel */}
<div className="flex-1 min-w-0">
{diffShas ? (
<ConfigDiffViewer
tenantId={tenantId}
deviceId={deviceId}
oldSha={diffShas[0]}
newSha={diffShas[1]}
oldDate={
backups?.find((b) => b.commit_sha === diffShas[0])?.created_at ?? ''
}
newDate={
backups?.find((b) => b.commit_sha === diffShas[1])?.created_at ?? ''
}
isEncrypted={
(backups?.find((b) => b.commit_sha === diffShas[0])?.encryption_tier ?? null) != null ||
(backups?.find((b) => b.commit_sha === diffShas[1])?.encryption_tier ?? null) != null
}
/>
) : (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted h-full flex items-center justify-center min-h-32">
{selectedShas.length < 2
? 'Select two backups from the timeline to compare'
: 'Click "Compare selected" to view the diff'}
</div>
)}
</div>
</div>
{/* Restore dialog */}
{restoreSha && (
<RestoreButton
tenantId={tenantId}
deviceId={deviceId}
commitSha={restoreSha}
backupDate={
restoreEntry
? new Date(restoreEntry.created_at).toLocaleString()
: restoreSha.slice(0, 7)
}
deviceHostname={deviceHostname}
open={restoreOpen}
onOpenChange={setRestoreOpen}
onComplete={handleRestoreComplete}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,214 @@
/**
* ConnTrackPanel -- Connection tracking settings panel.
*
* View/edit connection tracking (/ip/firewall/connection/tracking),
* timeout settings, max entries, active connection count.
* Safe apply mode by default.
*/
import { useState, useCallback } from 'react'
import { Pencil, Activity } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type PanelHook = ReturnType<typeof useConfigPanel>
// Timeout fields we expose for editing
const TIMEOUT_FIELDS = [
{ key: 'tcp-established-timeout', label: 'TCP Established' },
{ key: 'tcp-syn-sent-timeout', label: 'TCP SYN Sent' },
{ key: 'tcp-syn-received-timeout', label: 'TCP SYN Received' },
{ key: 'tcp-close-wait-timeout', label: 'TCP Close Wait' },
{ key: 'tcp-fin-wait-timeout', label: 'TCP FIN Wait' },
{ key: 'tcp-time-wait-timeout', label: 'TCP Time Wait' },
{ key: 'tcp-close-timeout', label: 'TCP Close' },
{ key: 'udp-timeout', label: 'UDP' },
{ key: 'udp-stream-timeout', label: 'UDP Stream' },
{ key: 'icmp-timeout', label: 'ICMP' },
{ key: 'generic-timeout', label: 'Generic' },
]
// ---------------------------------------------------------------------------
// ConnTrackPanel
// ---------------------------------------------------------------------------
export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const tracking = useConfigBrowse(tenantId, deviceId, '/ip/firewall/connection/tracking', { enabled: active })
const connections = useConfigBrowse(tenantId, deviceId, '/ip/firewall/connection', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'conntrack')
const [previewOpen, setPreviewOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [formData, setFormData] = useState<Record<string, string>>({})
const trackingData = tracking.entries[0] ?? {}
const activeCount = connections.entries.length
const handleEdit = useCallback(() => {
const data: Record<string, string> = {}
TIMEOUT_FIELDS.forEach((f) => { data[f.key] = trackingData[f.key] || '' })
data['max-entries'] = trackingData['max-entries'] || ''
data['enabled'] = trackingData['enabled'] || 'auto'
setFormData(data)
setEditOpen(true)
}, [trackingData])
const handleSave = useCallback(() => {
const props: Record<string, string> = {}
// Only include changed fields
Object.entries(formData).forEach(([key, value]) => {
if (value && value !== trackingData[key]) {
props[key] = value
}
})
if (Object.keys(props).length === 0) { setEditOpen(false); return }
panel.addChange({
operation: 'set',
path: '/ip/firewall/connection/tracking',
properties: props,
description: `Update connection tracking (${Object.keys(props).join(', ')})`,
})
setEditOpen(false)
}, [formData, trackingData, panel])
if (tracking.isLoading) {
return <div className="flex items-center justify-center py-12 text-text-secondary text-sm">Loading connection tracking...</div>
}
if (tracking.error) {
return <div className="flex items-center justify-center py-12 text-error text-sm">Failed to load. <button className="underline ml-1" onClick={() => tracking.refetch()}>Retry</button></div>
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* Active connections count */}
<div className="rounded-lg border border-border bg-surface p-4">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-accent" />
<div>
<span className="text-2xl font-bold text-text-primary">{activeCount}</span>
<span className="text-sm text-text-secondary ml-2">active connections</span>
</div>
{trackingData['max-entries'] && (
<span className="text-xs text-text-muted ml-auto">
max: {trackingData['max-entries']}
</span>
)}
</div>
</div>
{/* Tracking settings */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">Connection Tracking Settings</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
</div>
<div className="px-4 py-3 space-y-1.5">
<InfoRow label="Enabled" value={trackingData['enabled']} />
<InfoRow label="Max Entries" value={trackingData['max-entries']} />
<div className="border-t border-border/50 pt-2 mt-2">
<span className="text-xs font-medium text-text-secondary">Timeouts</span>
</div>
{TIMEOUT_FIELDS.map((f) => (
<InfoRow key={f.key} label={f.label} value={trackingData[f.key]} />
))}
</div>
</div>
{/* Edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Connection Tracking</DialogTitle>
<DialogDescription>Modify timeout values and max entries. Changes are staged.</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Enabled</Label>
<select
value={formData['enabled'] || 'auto'}
onChange={(e) => setFormData((f) => ({ ...f, enabled: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
<option value="auto">Auto</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Max Entries</Label>
<Input
type="number"
value={formData['max-entries'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, 'max-entries': e.target.value }))}
className="h-8 text-sm"
/>
</div>
</div>
<div className="border-t border-border pt-3">
<span className="text-xs font-medium text-text-secondary">Timeouts</span>
</div>
<div className="grid grid-cols-2 gap-3">
{TIMEOUT_FIELDS.map((f) => (
<div key={f.key} className="space-y-1">
<Label className="text-xs text-text-secondary">{f.label}</Label>
<Input
value={formData[f.key] || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [f.key]: e.target.value }))}
placeholder="00:05:00"
className="h-8 text-sm font-mono"
/>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>Stage Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ChangePreviewModal open={previewOpen} onOpenChange={setPreviewOpen} changes={panel.pendingChanges} applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }} isApplying={panel.isApplying} />
</div>
)
}
function InfoRow({ label, value }: { label: string; value?: string }) {
return (
<div className="flex items-start gap-4 py-1 border-b border-border/20 last:border-0">
<span className="text-xs text-text-muted w-32 flex-shrink-0">{label}</span>
<span className="text-sm text-text-primary font-mono">{value || '—'}</span>
</div>
)
}

View File

@@ -0,0 +1,476 @@
/**
* DhcpClientPanel -- DHCP Client management panel for device configuration.
*
* Displays /ip/dhcp-client entries with interface, status, obtained address,
* gateway, DNS, and options (use-peer-dns, use-peer-ntp, add-default-route).
* Supports add, edit, delete, enable/disable via the standard config panel workflow.
*/
import { useState, useCallback } from 'react'
import { Plus, Pencil, Trash2, Globe, Power, PowerOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Entry & form types
// ---------------------------------------------------------------------------
interface DhcpClientEntry {
'.id': string
interface: string
status: string
address: string
gateway: string
'primary-dns': string
'secondary-dns': string
'use-peer-dns': string
'use-peer-ntp': string
'add-default-route': string
disabled: string
dynamic: string
comment: string
[key: string]: string
}
interface DhcpClientForm {
interface: string
'use-peer-dns': boolean
'use-peer-ntp': boolean
'add-default-route': boolean
comment: string
}
const EMPTY_FORM: DhcpClientForm = {
interface: '',
'use-peer-dns': true,
'use-peer-ntp': true,
'add-default-route': true,
comment: '',
}
// ---------------------------------------------------------------------------
// DhcpClientPanel
// ---------------------------------------------------------------------------
export function DhcpClientPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const { entries, isLoading, error, refetch } = useConfigBrowse(
tenantId,
deviceId,
'/ip/dhcp-client',
{ enabled: active },
)
const panel = useConfigPanel(tenantId, deviceId, 'dhcp-client')
const [previewOpen, setPreviewOpen] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<DhcpClientForm>(EMPTY_FORM)
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const typedEntries = entries as DhcpClientEntry[]
const handleAdd = useCallback(() => {
setEditingId(null)
setForm(EMPTY_FORM)
setFormErrors({})
setDialogOpen(true)
}, [])
const handleEdit = useCallback((entry: DhcpClientEntry) => {
setEditingId(entry['.id'])
setForm({
interface: entry.interface || '',
'use-peer-dns': entry['use-peer-dns'] !== 'false',
'use-peer-ntp': entry['use-peer-ntp'] !== 'false',
'add-default-route': entry['add-default-route'] !== 'no',
comment: entry.comment || '',
})
setFormErrors({})
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(entry: DhcpClientEntry) => {
panel.addChange({
operation: 'remove',
path: '/ip/dhcp-client',
entryId: entry['.id'],
properties: {},
description: `Remove DHCP client on ${entry.interface}`,
})
},
[panel],
)
const handleToggle = useCallback(
(entry: DhcpClientEntry) => {
const newDisabled = entry.disabled === 'true' ? 'false' : 'true'
panel.addChange({
operation: 'set',
path: '/ip/dhcp-client',
entryId: entry['.id'],
properties: { disabled: newDisabled },
description: `${newDisabled === 'true' ? 'Disable' : 'Enable'} DHCP client on ${entry.interface}`,
})
},
[panel],
)
const handleSave = useCallback(() => {
const errors: Record<string, string> = {}
if (!form.interface.trim()) {
errors.interface = 'Interface is required'
}
if (Object.keys(errors).length > 0) {
setFormErrors(errors)
return
}
const properties: Record<string, string> = {
interface: form.interface.trim(),
'use-peer-dns': form['use-peer-dns'] ? 'yes' : 'no',
'use-peer-ntp': form['use-peer-ntp'] ? 'yes' : 'no',
'add-default-route': form['add-default-route'] ? 'yes' : 'no',
}
if (form.comment.trim()) {
properties.comment = form.comment.trim()
}
if (editingId) {
panel.addChange({
operation: 'set',
path: '/ip/dhcp-client',
entryId: editingId,
properties,
description: `Update DHCP client on ${form.interface}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/ip/dhcp-client',
properties,
description: `Add DHCP client on ${form.interface}`,
})
}
setDialogOpen(false)
}, [form, editingId, panel])
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading DHCP clients...
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load DHCP clients.{' '}
<button className="underline ml-1" onClick={() => refetch()}>
Retry
</button>
</div>
)
}
return (
<div className="space-y-4">
{/* Header with SafetyToggle and Apply button */}
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* DHCP Client table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Globe className="h-4 w-4" />
DHCP Clients ({typedEntries.length})
</div>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" />
Add DHCP Client
</Button>
</div>
{typedEntries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
No DHCP clients configured.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Interface</th>
<th className="text-left px-4 py-2 font-medium">Status</th>
<th className="text-left px-4 py-2 font-medium">Address</th>
<th className="text-left px-4 py-2 font-medium">Gateway</th>
<th className="text-left px-4 py-2 font-medium">DNS</th>
<th className="text-left px-4 py-2 font-medium">Options</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{typedEntries.map((entry) => (
<tr
key={entry['.id']}
className={cn(
'border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors',
entry.disabled === 'true' && 'opacity-50',
)}
>
<td className="px-4 py-2 font-mono text-text-primary">
{entry.interface || '—'}
</td>
<td className="px-4 py-2">
<StatusBadge status={entry.status} disabled={entry.disabled === 'true'} />
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{entry.address || '—'}
</td>
<td className="px-4 py-2 font-mono text-text-secondary">
{entry.gateway || '—'}
</td>
<td className="px-4 py-2 text-text-secondary text-xs">
{entry['primary-dns'] || '—'}
{entry['secondary-dns'] && `, ${entry['secondary-dns']}`}
</td>
<td className="px-4 py-2">
<div className="flex gap-1 flex-wrap">
{entry['use-peer-dns'] !== 'false' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent/10 text-accent">DNS</span>
)}
{entry['use-peer-ntp'] !== 'false' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent/10 text-accent">NTP</span>
)}
{entry['add-default-route'] !== 'no' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent/10 text-accent">Route</span>
)}
</div>
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleToggle(entry)}
title={entry.disabled === 'true' ? 'Enable' : 'Disable'}
>
{entry.disabled === 'true' ? (
<PowerOff className="h-3.5 w-3.5 text-text-muted" />
) : (
<Power className="h-3.5 w-3.5 text-success" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleEdit(entry)}
title="Edit DHCP client"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-error hover:text-error"
onClick={() => handleDelete(entry)}
title="Delete DHCP client"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Add/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? 'Edit' : 'Add'} DHCP Client</DialogTitle>
<DialogDescription>
{editingId
? 'Update DHCP client settings. Changes are staged until you apply them.'
: 'Add a DHCP client on an interface to obtain an IP address automatically.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label htmlFor="dhcpc-interface" className="text-xs text-text-secondary">
Interface
</Label>
<Input
id="dhcpc-interface"
value={form.interface}
onChange={(e) => setForm((f) => ({ ...f, interface: e.target.value }))}
placeholder="ether1"
disabled={!!editingId}
className={cn('h-8 text-sm', formErrors.interface && 'border-error')}
/>
{formErrors.interface && (
<p className="text-xs text-error">{formErrors.interface}</p>
)}
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
id="dhcpc-peer-dns"
checked={form['use-peer-dns']}
onCheckedChange={(checked) =>
setForm((f) => ({ ...f, 'use-peer-dns': !!checked }))
}
/>
<Label htmlFor="dhcpc-peer-dns" className="text-sm">
Use peer DNS
</Label>
<span className="text-xs text-text-muted">Accept DNS servers from DHCP server</span>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="dhcpc-peer-ntp"
checked={form['use-peer-ntp']}
onCheckedChange={(checked) =>
setForm((f) => ({ ...f, 'use-peer-ntp': !!checked }))
}
/>
<Label htmlFor="dhcpc-peer-ntp" className="text-sm">
Use peer NTP
</Label>
<span className="text-xs text-text-muted">Accept time servers from DHCP server</span>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="dhcpc-default-route"
checked={form['add-default-route']}
onCheckedChange={(checked) =>
setForm((f) => ({ ...f, 'add-default-route': !!checked }))
}
/>
<Label htmlFor="dhcpc-default-route" className="text-sm">
Add default route
</Label>
<span className="text-xs text-text-muted">Create default gateway via this connection</span>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="dhcpc-comment" className="text-xs text-text-secondary">
Comment
</Label>
<Input
id="dhcpc-comment"
value={form.comment}
onChange={(e) => setForm((f) => ({ ...f, comment: e.target.value }))}
placeholder="WAN connection"
className="h-8 text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
{editingId ? 'Stage Changes' : 'Stage DHCP Client'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Change Preview Modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Status Badge
// ---------------------------------------------------------------------------
function StatusBadge({ status, disabled }: { status: string; disabled: boolean }) {
if (disabled) {
return (
<Badge variant="outline" className="text-[10px] text-text-muted border-border">
disabled
</Badge>
)
}
switch (status) {
case 'bound':
return (
<Badge variant="outline" className="text-[10px] text-success border-success/40 bg-success/10">
bound
</Badge>
)
case 'searching':
return (
<Badge variant="outline" className="text-[10px] text-warning border-warning/40 bg-warning/10">
searching
</Badge>
)
case 'requesting':
case 'rebinding':
case 'renewing':
return (
<Badge variant="outline" className="text-[10px] text-accent border-accent/40 bg-accent/10">
{status}
</Badge>
)
default:
return (
<Badge variant="outline" className="text-[10px] text-text-muted border-border">
{status || '—'}
</Badge>
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,613 @@
/**
* DnsPanel -- DNS configuration panel for device management.
*
* Provides:
* 1. Resolver settings (upstream servers, allow-remote-requests, cache-size, max-udp-packet-size)
* 2. Static DNS entries (add/edit/delete name/address/type/TTL)
* 3. Read-only cache usage info
*
* All operations flow through useConfigPanel for pending changes and apply workflow.
*/
import { useState, useCallback } from 'react'
import {
Plus,
Pencil,
Trash2,
Save,
Globe,
Server,
Info,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT'] as const
interface StaticEntry {
'.id': string
name: string
address: string
type: string
ttl: string
disabled: string
[key: string]: string
}
interface StaticFormState {
name: string
address: string
type: string
ttl: string
disabled: boolean
}
const EMPTY_FORM: StaticFormState = {
name: '',
address: '',
type: 'A',
ttl: '',
disabled: false,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isValidIp(value: string): boolean {
// Basic IPv4/IPv6 validation
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/
const ipv6 = /^[0-9a-fA-F:]+$/
return ipv4.test(value) || ipv6.test(value)
}
// ---------------------------------------------------------------------------
// DnsPanel
// ---------------------------------------------------------------------------
export function DnsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
// Data loading
const dnsSettings = useConfigBrowse(tenantId, deviceId, '/ip/dns', {
enabled: active,
})
const staticEntries = useConfigBrowse(tenantId, deviceId, '/ip/dns/static', {
enabled: active,
})
// Config panel state
const panel = useConfigPanel(tenantId, deviceId, 'dns')
// Preview modal
const [previewOpen, setPreviewOpen] = useState(false)
// Static entry dialog state
const [dialogOpen, setDialogOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState<StaticEntry | null>(null)
const [form, setForm] = useState<StaticFormState>(EMPTY_FORM)
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
// Local state for resolver settings edits
const settings = dnsSettings.entries[0] as Record<string, string> | undefined
const [editedServers, setEditedServers] = useState<string | null>(null)
const [editedAllowRemote, setEditedAllowRemote] = useState<string | null>(null)
const [editedMaxUdp, setEditedMaxUdp] = useState<string | null>(null)
const [editedCacheSize, setEditedCacheSize] = useState<string | null>(null)
// Derived values -- use edited state if set, otherwise fall back to server data
const currentServers = editedServers ?? settings?.servers ?? ''
const currentAllowRemote = editedAllowRemote ?? settings?.['allow-remote-requests'] ?? 'false'
const currentMaxUdp = editedMaxUdp ?? settings?.['max-udp-packet-size'] ?? '4096'
const currentCacheSize = editedCacheSize ?? settings?.['cache-size'] ?? '2048'
const cacheUsed = settings?.['cache-used'] ?? '0'
// Check if resolver settings have been modified
const settingsModified =
editedServers !== null ||
editedAllowRemote !== null ||
editedMaxUdp !== null ||
editedCacheSize !== null
// Save resolver settings
const handleSaveSettings = useCallback(() => {
const props: Record<string, string> = {}
if (editedServers !== null) props.servers = editedServers
if (editedAllowRemote !== null) props['allow-remote-requests'] = editedAllowRemote
if (editedMaxUdp !== null) props['max-udp-packet-size'] = editedMaxUdp
if (editedCacheSize !== null) props['cache-size'] = editedCacheSize
if (Object.keys(props).length === 0) return
const descParts: string[] = []
if (props.servers !== undefined) descParts.push(`servers=${props.servers}`)
if (props['allow-remote-requests'] !== undefined)
descParts.push(`allow-remote-requests=${props['allow-remote-requests']}`)
if (props['max-udp-packet-size'] !== undefined)
descParts.push(`max-udp-packet-size=${props['max-udp-packet-size']}`)
if (props['cache-size'] !== undefined)
descParts.push(`cache-size=${props['cache-size']}`)
panel.addChange({
operation: 'set',
path: '/ip/dns',
entryId: settings?.['.id'],
properties: props,
description: `Update DNS resolver: ${descParts.join(', ')}`,
})
// Reset edited state
setEditedServers(null)
setEditedAllowRemote(null)
setEditedMaxUdp(null)
setEditedCacheSize(null)
}, [
editedServers,
editedAllowRemote,
editedMaxUdp,
editedCacheSize,
settings,
panel,
])
// Static entry form validation
const validateForm = useCallback((f: StaticFormState): Record<string, string> => {
const errors: Record<string, string> = {}
if (!f.name.trim()) errors.name = 'Name is required'
if (!f.address.trim()) errors.address = 'Address is required'
else if (f.type === 'A' || f.type === 'AAAA') {
if (!isValidIp(f.address)) errors.address = 'Invalid IP address'
}
return errors
}, [])
// Open add dialog
const handleAdd = useCallback(() => {
setEditingEntry(null)
setForm(EMPTY_FORM)
setFormErrors({})
setDialogOpen(true)
}, [])
// Open edit dialog
const handleEdit = useCallback((entry: StaticEntry) => {
setEditingEntry(entry)
setForm({
name: entry.name || '',
address: entry.address || '',
type: entry.type || 'A',
ttl: entry.ttl || '',
disabled: entry.disabled === 'true',
})
setFormErrors({})
setDialogOpen(true)
}, [])
// Delete entry
const handleDelete = useCallback(
(entry: StaticEntry) => {
panel.addChange({
operation: 'remove',
path: '/ip/dns/static',
entryId: entry['.id'],
properties: {},
description: `Delete static DNS: ${entry.name} -> ${entry.address}`,
})
},
[panel],
)
// Submit static entry form
const handleSubmitEntry = useCallback(() => {
const errors = validateForm(form)
if (Object.keys(errors).length > 0) {
setFormErrors(errors)
return
}
const props: Record<string, string> = {
name: form.name.trim(),
address: form.address.trim(),
type: form.type,
}
if (form.ttl.trim()) props.ttl = form.ttl.trim()
if (form.disabled) props.disabled = 'true'
if (editingEntry) {
panel.addChange({
operation: 'set',
path: '/ip/dns/static',
entryId: editingEntry['.id'],
properties: props,
description: `Edit static DNS: ${form.name} -> ${form.address}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/ip/dns/static',
properties: props,
description: `Add static DNS: ${form.name} -> ${form.address} (${form.type})`,
})
}
setDialogOpen(false)
}, [form, editingEntry, validateForm, panel])
// Loading state
if (dnsSettings.isLoading || staticEntries.isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading DNS configuration...
</div>
)
}
// Error state
if (dnsSettings.error || staticEntries.error) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load DNS configuration.{' '}
<button
className="underline ml-1"
onClick={() => {
dnsSettings.refetch()
staticEntries.refetch()
}}
>
Retry
</button>
</div>
)
}
const entries = staticEntries.entries as StaticEntry[]
return (
<div className="space-y-6">
{/* Header with SafetyToggle and Apply button */}
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* Section 1: Resolver Settings */}
<div className="rounded-lg border border-border bg-surface p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-text-primary">Resolver Settings</h3>
</div>
<div className="flex items-center gap-2">
<Badge className="text-xs bg-info/10 text-info px-2 py-0.5 rounded border-0">
<Info className="h-3 w-3 mr-1" />
Cache Used: {cacheUsed} KiB
</Badge>
<Button
variant="outline"
size="sm"
disabled={!settingsModified}
onClick={handleSaveSettings}
className="gap-1.5"
>
<Save className="h-3.5 w-3.5" />
Save Settings
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Servers */}
<div className="space-y-1 sm:col-span-2">
<Label htmlFor="dns-servers" className="text-xs text-text-secondary">
Upstream DNS Servers
</Label>
<Input
id="dns-servers"
value={currentServers}
onChange={(e) => setEditedServers(e.target.value)}
placeholder="8.8.8.8,8.8.4.4"
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted">Comma-separated list of DNS server IPs</p>
</div>
{/* Allow Remote Requests */}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Allow Remote Requests</Label>
<div className="flex items-center gap-2 pt-1">
<Checkbox
id="dns-allow-remote"
checked={currentAllowRemote === 'true' || currentAllowRemote === 'yes'}
onCheckedChange={(checked) =>
setEditedAllowRemote(checked ? 'yes' : 'no')
}
/>
<Label htmlFor="dns-allow-remote" className="text-sm text-text-primary cursor-pointer">
Allow this router to be used as a DNS server
</Label>
</div>
</div>
{/* Max UDP Packet Size */}
<div className="space-y-1">
<Label htmlFor="dns-max-udp" className="text-xs text-text-secondary">
Max UDP Packet Size
</Label>
<Input
id="dns-max-udp"
type="number"
value={currentMaxUdp}
onChange={(e) => setEditedMaxUdp(e.target.value)}
min={512}
max={65535}
className="text-sm"
/>
</div>
{/* Cache Size */}
<div className="space-y-1">
<Label htmlFor="dns-cache-size" className="text-xs text-text-secondary">
Cache Size (KiB)
</Label>
<Input
id="dns-cache-size"
type="number"
value={currentCacheSize}
onChange={(e) => setEditedCacheSize(e.target.value)}
min={0}
className="text-sm"
/>
</div>
</div>
</div>
{/* Section 2: Static DNS Entries */}
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-text-primary">Static DNS Entries</h3>
</div>
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add Entry
</Button>
</div>
{entries.length === 0 ? (
<div className="text-center py-8 text-text-secondary text-sm">
No static DNS entries configured.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-text-secondary text-xs">
<th className="text-left py-2 px-3 font-medium">Name</th>
<th className="text-left py-2 px-3 font-medium">Address</th>
<th className="text-left py-2 px-3 font-medium">Type</th>
<th className="text-left py-2 px-3 font-medium">TTL</th>
<th className="text-left py-2 px-3 font-medium">Disabled</th>
<th className="text-right py-2 px-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry['.id']}
className="border-b border-border/50 hover:bg-elevated/50 transition-colors"
>
<td className="py-2 px-3 font-mono text-text-primary">
{entry.name}
</td>
<td className="py-2 px-3 font-mono text-text-primary">
{entry.address}
</td>
<td className="py-2 px-3">
<Badge variant="outline" className="text-xs">
{entry.type || 'A'}
</Badge>
</td>
<td className="py-2 px-3 text-text-secondary">
{entry.ttl || '-'}
</td>
<td className="py-2 px-3">
{entry.disabled === 'true' ? (
<Badge className="text-xs bg-warning/10 text-warning border-0">
Yes
</Badge>
) : (
<span className="text-text-muted text-xs">No</span>
)}
</td>
<td className="py-2 px-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(entry)}
className="h-7 w-7 p-0"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(entry)}
className="h-7 w-7 p-0 text-error hover:text-error"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Static DNS Entry Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingEntry ? 'Edit Static DNS Entry' : 'Add Static DNS Entry'}
</DialogTitle>
<DialogDescription>
{editingEntry
? 'Modify the DNS record properties.'
: 'Create a new static DNS record.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Name */}
<div className="space-y-1">
<Label htmlFor="static-name" className="text-xs text-text-secondary">
Name <span className="text-error">*</span>
</Label>
<Input
id="static-name"
value={form.name}
onChange={(e) => {
setForm((f) => ({ ...f, name: e.target.value }))
setFormErrors((prev) => ({ ...prev, name: '' }))
}}
placeholder="myserver.local"
className={formErrors.name ? 'border-error' : ''}
/>
{formErrors.name && (
<p className="text-xs text-error">{formErrors.name}</p>
)}
</div>
{/* Address */}
<div className="space-y-1">
<Label htmlFor="static-address" className="text-xs text-text-secondary">
Address <span className="text-error">*</span>
</Label>
<Input
id="static-address"
value={form.address}
onChange={(e) => {
setForm((f) => ({ ...f, address: e.target.value }))
setFormErrors((prev) => ({ ...prev, address: '' }))
}}
placeholder="192.168.1.100"
className={formErrors.address ? 'border-error' : ''}
/>
{formErrors.address && (
<p className="text-xs text-error">{formErrors.address}</p>
)}
</div>
{/* Type */}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Type</Label>
<Select
value={form.type}
onValueChange={(value) => setForm((f) => ({ ...f, type: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DNS_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* TTL */}
<div className="space-y-1">
<Label htmlFor="static-ttl" className="text-xs text-text-secondary">
TTL
</Label>
<Input
id="static-ttl"
value={form.ttl}
onChange={(e) => setForm((f) => ({ ...f, ttl: e.target.value }))}
placeholder="1d (optional)"
/>
<p className="text-xs text-text-muted">
Examples: 1d, 1h, 300s, or leave blank for default
</p>
</div>
{/* Disabled */}
<div className="flex items-center gap-2">
<Checkbox
id="static-disabled"
checked={form.disabled}
onCheckedChange={(checked) =>
setForm((f) => ({ ...f, disabled: !!checked }))
}
/>
<Label htmlFor="static-disabled" className="text-sm text-text-primary cursor-pointer">
Disabled
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmitEntry}>
{editingEntry ? 'Update Entry' : 'Add Entry'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Change Preview Modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
/**
* IpsecPanel -- IPsec configuration panel.
*
* Sub-tabs:
* 1. Peers -- IPsec peer configuration
* 2. Policies -- IPsec policies with src/dst address
* 3. Proposals -- Encryption/auth algorithm proposals
* 4. Active SAs -- Installed security associations (read-only)
*
* Safe apply mode by default.
*/
import { useState, useCallback } from 'react'
import { Plus, Pencil, Trash2, Shield, Lock, Key, Activity } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Sub-tab types
// ---------------------------------------------------------------------------
type SubTab = 'peers' | 'policies' | 'proposals' | 'sas'
const SUB_TABS: { key: SubTab; label: string; icon: React.ReactNode }[] = [
{ key: 'peers', label: 'Peers', icon: <Shield className="h-3.5 w-3.5" /> },
{ key: 'policies', label: 'Policies', icon: <Lock className="h-3.5 w-3.5" /> },
{ key: 'proposals', label: 'Proposals', icon: <Key className="h-3.5 w-3.5" /> },
{ key: 'sas', label: 'Active SAs', icon: <Activity className="h-3.5 w-3.5" /> },
]
// ---------------------------------------------------------------------------
// Entry types
// ---------------------------------------------------------------------------
interface PeerEntry { '.id': string; address: string; 'auth-method': string; secret: string; 'exchange-mode': string; disabled: string; [key: string]: string }
interface PolicyEntry { '.id': string; 'src-address': string; 'dst-address': string; tunnel: string; action: string; level: string; proposal: string; disabled: string; [key: string]: string }
interface ProposalEntry { '.id': string; name: string; 'auth-algorithms': string; 'enc-algorithms': string; 'pfs-group': string; [key: string]: string }
interface SaEntry { '.id': string; 'src-address': string; 'dst-address': string; state: string; 'auth-algorithm': string; 'enc-algorithm': string; [key: string]: string }
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// IpsecPanel
// ---------------------------------------------------------------------------
export function IpsecPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const [activeTab, setActiveTab] = useState<SubTab>('peers')
const peers = useConfigBrowse(tenantId, deviceId, '/ip/ipsec/peer', { enabled: active })
const policies = useConfigBrowse(tenantId, deviceId, '/ip/ipsec/policy', { enabled: active })
const proposals = useConfigBrowse(tenantId, deviceId, '/ip/ipsec/proposal', { enabled: active })
const sas = useConfigBrowse(tenantId, deviceId, '/ip/ipsec/installed-sa', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'ipsec')
const [previewOpen, setPreviewOpen] = useState(false)
const isLoading = peers.isLoading || policies.isLoading || proposals.isLoading
if (isLoading) {
return <div className="flex items-center justify-center py-12 text-text-secondary text-sm">Loading IPsec configuration...</div>
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<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})
</Button>
</div>
<div className="flex gap-1 p-1 rounded-lg bg-elevated">
{SUB_TABS.map((tab) => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
className={cn('flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
activeTab === tab.key ? 'bg-surface text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-surface/50')}>
{tab.icon}{tab.label}
</button>
))}
</div>
{activeTab === 'peers' && <PeersTab entries={peers.entries as PeerEntry[]} panel={panel} />}
{activeTab === 'policies' && <PoliciesTab entries={policies.entries as PolicyEntry[]} panel={panel} />}
{activeTab === 'proposals' && <ProposalsTab entries={proposals.entries as ProposalEntry[]} panel={panel} />}
{activeTab === 'sas' && <SasTab entries={sas.entries as SaEntry[]} />}
<ChangePreviewModal open={previewOpen} onOpenChange={setPreviewOpen} changes={panel.pendingChanges} applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }} isApplying={panel.isApplying} />
</div>
)
}
// ---------------------------------------------------------------------------
// Peers Tab
// ---------------------------------------------------------------------------
function PeersTab({ entries, panel }: { entries: PeerEntry[]; panel: PanelHook }) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<PeerEntry | null>(null)
const [form, setForm] = useState({ address: '', 'auth-method': 'pre-shared-key', secret: '', 'exchange-mode': 'main' })
const handleAdd = useCallback(() => { setEditing(null); setForm({ address: '', 'auth-method': 'pre-shared-key', secret: '', 'exchange-mode': 'main' }); setDialogOpen(true) }, [])
const handleEdit = useCallback((e: PeerEntry) => { setEditing(e); setForm({ address: e.address || '', 'auth-method': e['auth-method'] || 'pre-shared-key', secret: '', 'exchange-mode': e['exchange-mode'] || 'main' }); setDialogOpen(true) }, [])
const handleDelete = useCallback((e: PeerEntry) => { panel.addChange({ operation: 'remove', path: '/ip/ipsec/peer', entryId: e['.id'], properties: {}, description: `Remove IPsec peer ${e.address}` }) }, [panel])
const handleSave = useCallback(() => {
if (!form.address) return
const props: Record<string, string> = { address: form.address, 'auth-method': form['auth-method'], 'exchange-mode': form['exchange-mode'] }
if (form.secret) props.secret = form.secret
if (editing) panel.addChange({ operation: 'set', path: '/ip/ipsec/peer', entryId: editing['.id'], properties: props, description: `Edit IPsec peer ${form.address}` })
else panel.addChange({ operation: 'add', path: '/ip/ipsec/peer', properties: props, description: `Add IPsec peer ${form.address}` })
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<TableWrapper title="IPsec Peers" count={entries.length} onAdd={handleAdd}>
<thead><tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Address</th><th className="text-left px-4 py-2 font-medium">Auth Method</th>
<th className="text-left px-4 py-2 font-medium">Exchange Mode</th><th className="text-left px-4 py-2 font-medium">Status</th><th className="text-right px-4 py-2 font-medium">Actions</th>
</tr></thead><tbody>
{entries.map((e) => <tr key={e['.id']} className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 font-mono text-text-primary">{e.address || '—'}</td>
<td className="px-4 py-2 text-text-secondary">{e['auth-method'] || '—'}</td>
<td className="px-4 py-2 text-text-secondary">{e['exchange-mode'] || '—'}</td>
<td className="px-4 py-2">{e.disabled === 'true' ? <span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-error/10 text-error border-error/40">disabled</span> : <span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">active</span>}</td>
<td className="px-4 py-2"><div className="flex items-center justify-end gap-1"><Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => handleEdit(e)}><Pencil className="h-3.5 w-3.5" /></Button><Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-error hover:text-error" onClick={() => handleDelete(e)}><Trash2 className="h-3.5 w-3.5" /></Button></div></td>
</tr>)}
</tbody>
</TableWrapper>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}><DialogContent>
<DialogHeader><DialogTitle>{editing ? 'Edit Peer' : 'Add Peer'}</DialogTitle><DialogDescription>IPsec peer configuration.</DialogDescription></DialogHeader>
<div className="space-y-3 mt-2">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Address</Label><Input value={form.address} onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} placeholder="0.0.0.0/0" className="h-8 text-sm font-mono" /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Auth Method</Label>
<select value={form['auth-method']} onChange={(e) => setForm((f) => ({ ...f, 'auth-method': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
<option value="pre-shared-key">Pre-Shared Key</option><option value="rsa-key">RSA Key</option><option value="rsa-signature">RSA Signature</option>
</select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Exchange Mode</Label>
<select value={form['exchange-mode']} onChange={(e) => setForm((f) => ({ ...f, 'exchange-mode': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
<option value="main">Main</option><option value="aggressive">Aggressive</option><option value="ike2">IKEv2</option>
</select></div>
</div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Secret {editing && '(blank = keep)'}</Label><Input type="password" value={form.secret} onChange={(e) => setForm((f) => ({ ...f, secret: e.target.value }))} className="h-8 text-sm" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={handleSave}>{editing ? 'Stage Edit' : 'Stage Peer'}</Button></DialogFooter>
</DialogContent></Dialog>
</>
)
}
// ---------------------------------------------------------------------------
// Policies Tab
// ---------------------------------------------------------------------------
function PoliciesTab({ entries, panel }: { entries: PolicyEntry[]; panel: PanelHook }) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<PolicyEntry | null>(null)
const [form, setForm] = useState({ 'src-address': '', 'dst-address': '', tunnel: 'yes', action: 'encrypt', level: 'require', proposal: 'default' })
const handleAdd = useCallback(() => { setEditing(null); setForm({ 'src-address': '', 'dst-address': '', tunnel: 'yes', action: 'encrypt', level: 'require', proposal: 'default' }); setDialogOpen(true) }, [])
const handleEdit = useCallback((e: PolicyEntry) => { setEditing(e); setForm({ 'src-address': e['src-address'] || '', 'dst-address': e['dst-address'] || '', tunnel: e.tunnel || 'yes', action: e.action || 'encrypt', level: e.level || 'require', proposal: e.proposal || 'default' }); setDialogOpen(true) }, [])
const handleDelete = useCallback((e: PolicyEntry) => { panel.addChange({ operation: 'remove', path: '/ip/ipsec/policy', entryId: e['.id'], properties: {}, description: `Remove IPsec policy ${e['src-address']}${e['dst-address']}` }) }, [panel])
const handleSave = useCallback(() => {
const props: Record<string, string> = { 'src-address': form['src-address'], 'dst-address': form['dst-address'], tunnel: form.tunnel, action: form.action, level: form.level, proposal: form.proposal }
if (editing) panel.addChange({ operation: 'set', path: '/ip/ipsec/policy', entryId: editing['.id'], properties: props, description: `Edit IPsec policy` })
else panel.addChange({ operation: 'add', path: '/ip/ipsec/policy', properties: props, description: `Add IPsec policy ${form['src-address']}${form['dst-address']}` })
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<TableWrapper title="IPsec Policies" count={entries.length} onAdd={handleAdd}>
<thead><tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Src Address</th><th className="text-left px-4 py-2 font-medium">Dst Address</th>
<th className="text-left px-4 py-2 font-medium">Tunnel</th><th className="text-left px-4 py-2 font-medium">Action</th>
<th className="text-left px-4 py-2 font-medium">Proposal</th><th className="text-right px-4 py-2 font-medium">Actions</th>
</tr></thead><tbody>
{entries.map((e) => <tr key={e['.id']} className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 font-mono text-text-primary text-xs">{e['src-address'] || '—'}</td>
<td className="px-4 py-2 font-mono text-text-primary text-xs">{e['dst-address'] || '—'}</td>
<td className="px-4 py-2 text-text-secondary">{e.tunnel}</td>
<td className="px-4 py-2"><span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-info/10 text-info border-info/40">{e.action}</span></td>
<td className="px-4 py-2 text-text-secondary">{e.proposal || '—'}</td>
<td className="px-4 py-2"><div className="flex items-center justify-end gap-1"><Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => handleEdit(e)}><Pencil className="h-3.5 w-3.5" /></Button><Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-error hover:text-error" onClick={() => handleDelete(e)}><Trash2 className="h-3.5 w-3.5" /></Button></div></td>
</tr>)}
</tbody>
</TableWrapper>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}><DialogContent>
<DialogHeader><DialogTitle>{editing ? 'Edit Policy' : 'Add Policy'}</DialogTitle><DialogDescription>IPsec policy settings.</DialogDescription></DialogHeader>
<div className="space-y-3 mt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Src Address</Label><Input value={form['src-address']} onChange={(e) => setForm((f) => ({ ...f, 'src-address': e.target.value }))} placeholder="192.168.1.0/24" className="h-8 text-sm font-mono" /></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Dst Address</Label><Input value={form['dst-address']} onChange={(e) => setForm((f) => ({ ...f, 'dst-address': e.target.value }))} placeholder="10.0.0.0/24" className="h-8 text-sm font-mono" /></div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Tunnel</Label><select value={form.tunnel} onChange={(e) => setForm((f) => ({ ...f, tunnel: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"><option value="yes">Yes</option><option value="no">No</option></select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Action</Label><select value={form.action} onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"><option value="encrypt">Encrypt</option><option value="none">None</option></select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Proposal</Label><Input value={form.proposal} onChange={(e) => setForm((f) => ({ ...f, proposal: e.target.value }))} className="h-8 text-sm" /></div>
</div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={handleSave}>{editing ? 'Stage Edit' : 'Stage Policy'}</Button></DialogFooter>
</DialogContent></Dialog>
</>
)
}
// ---------------------------------------------------------------------------
// Proposals Tab
// ---------------------------------------------------------------------------
function ProposalsTab({ entries, panel }: { entries: ProposalEntry[]; panel: PanelHook }) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<ProposalEntry | null>(null)
const [form, setForm] = useState({ name: '', 'auth-algorithms': 'sha256', 'enc-algorithms': 'aes-256-cbc', 'pfs-group': 'modp2048' })
const handleAdd = useCallback(() => { setEditing(null); setForm({ name: '', 'auth-algorithms': 'sha256', 'enc-algorithms': 'aes-256-cbc', 'pfs-group': 'modp2048' }); setDialogOpen(true) }, [])
const handleEdit = useCallback((e: ProposalEntry) => { setEditing(e); setForm({ name: e.name || '', 'auth-algorithms': e['auth-algorithms'] || '', 'enc-algorithms': e['enc-algorithms'] || '', 'pfs-group': e['pfs-group'] || '' }); setDialogOpen(true) }, [])
const handleDelete = useCallback((e: ProposalEntry) => { panel.addChange({ operation: 'remove', path: '/ip/ipsec/proposal', entryId: e['.id'], properties: {}, description: `Remove IPsec proposal "${e.name}"` }) }, [panel])
const handleSave = useCallback(() => {
if (!form.name) return
const props: Record<string, string> = { name: form.name, 'auth-algorithms': form['auth-algorithms'], 'enc-algorithms': form['enc-algorithms'], 'pfs-group': form['pfs-group'] }
if (editing) panel.addChange({ operation: 'set', path: '/ip/ipsec/proposal', entryId: editing['.id'], properties: props, description: `Edit IPsec proposal "${form.name}"` })
else panel.addChange({ operation: 'add', path: '/ip/ipsec/proposal', properties: props, description: `Add IPsec proposal "${form.name}"` })
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<TableWrapper title="IPsec Proposals" count={entries.length} onAdd={handleAdd}>
<thead><tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Name</th><th className="text-left px-4 py-2 font-medium">Auth</th>
<th className="text-left px-4 py-2 font-medium">Encryption</th><th className="text-left px-4 py-2 font-medium">PFS Group</th><th className="text-right px-4 py-2 font-medium">Actions</th>
</tr></thead><tbody>
{entries.map((e) => <tr key={e['.id']} className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 text-text-primary font-medium">{e.name}</td>
<td className="px-4 py-2 text-text-secondary text-xs">{e['auth-algorithms'] || '—'}</td>
<td className="px-4 py-2 text-text-secondary text-xs">{e['enc-algorithms'] || '—'}</td>
<td className="px-4 py-2 text-text-secondary">{e['pfs-group'] || '—'}</td>
<td className="px-4 py-2"><div className="flex items-center justify-end gap-1"><Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => handleEdit(e)}><Pencil className="h-3.5 w-3.5" /></Button><Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-error hover:text-error" onClick={() => handleDelete(e)}><Trash2 className="h-3.5 w-3.5" /></Button></div></td>
</tr>)}
</tbody>
</TableWrapper>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}><DialogContent>
<DialogHeader><DialogTitle>{editing ? 'Edit Proposal' : 'Add Proposal'}</DialogTitle><DialogDescription>IPsec proposal settings.</DialogDescription></DialogHeader>
<div className="space-y-3 mt-2">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Name</Label><Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} className="h-8 text-sm" /></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Auth Algorithms</Label><Input value={form['auth-algorithms']} onChange={(e) => setForm((f) => ({ ...f, 'auth-algorithms': e.target.value }))} placeholder="sha256" className="h-8 text-sm" /></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Enc Algorithms</Label><Input value={form['enc-algorithms']} onChange={(e) => setForm((f) => ({ ...f, 'enc-algorithms': e.target.value }))} placeholder="aes-256-cbc" className="h-8 text-sm" /></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">PFS Group</Label><Input value={form['pfs-group']} onChange={(e) => setForm((f) => ({ ...f, 'pfs-group': e.target.value }))} placeholder="modp2048" className="h-8 text-sm" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={handleSave}>{editing ? 'Stage Edit' : 'Stage Proposal'}</Button></DialogFooter>
</DialogContent></Dialog>
</>
)
}
// ---------------------------------------------------------------------------
// Active SAs Tab (read-only)
// ---------------------------------------------------------------------------
function SasTab({ entries }: { entries: SaEntry[] }) {
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="px-4 py-2 border-b border-border/50"><span className="text-sm font-medium text-text-secondary">Installed SAs ({entries.length})</span></div>
{entries.length === 0 ? <div className="px-4 py-8 text-center text-sm text-text-muted">No active security associations.</div> : (
<div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Src Address</th><th className="text-left px-4 py-2 font-medium">Dst Address</th>
<th className="text-left px-4 py-2 font-medium">State</th><th className="text-left px-4 py-2 font-medium">Auth</th><th className="text-left px-4 py-2 font-medium">Encryption</th>
</tr></thead><tbody>
{entries.map((e) => <tr key={e['.id']} className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 font-mono text-text-primary text-xs">{e['src-address']}</td>
<td className="px-4 py-2 font-mono text-text-primary text-xs">{e['dst-address']}</td>
<td className="px-4 py-2"><span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">{e.state || 'mature'}</span></td>
<td className="px-4 py-2 text-text-secondary text-xs">{e['auth-algorithm'] || '—'}</td>
<td className="px-4 py-2 text-text-secondary text-xs">{e['enc-algorithm'] || '—'}</td>
</tr>)}
</tbody></table></div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Table Wrapper
// ---------------------------------------------------------------------------
function TableWrapper({ title, count, onAdd, children }: { title: string; count: number; onAdd: () => void; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">{title} ({count})</span>
<Button size="sm" variant="outline" className="gap-1" onClick={onAdd}><Plus className="h-3.5 w-3.5" />Add</Button>
</div>
{count === 0 ? <div className="px-4 py-8 text-center text-sm text-text-muted">No entries found.</div> : (
<div className="overflow-x-auto"><table className="w-full text-sm">{children}</table></div>
)}
</div>
)
}

View File

@@ -0,0 +1,320 @@
/**
* ManglePanel -- Firewall mangle rules management.
*
* View/add/edit/delete mangle rules (/ip/firewall/mangle),
* chain selector, action types, move up/down for rule ordering.
* Safe apply mode by default.
*/
import { useState, useCallback, useMemo } from 'react'
import { Plus, Pencil, Trash2, ArrowUp, ArrowDown, Filter } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ChainFilter = 'all' | 'prerouting' | 'input' | 'forward' | 'output' | 'postrouting'
const CHAINS: ChainFilter[] = ['all', 'prerouting', 'input', 'forward', 'output', 'postrouting']
const ACTIONS = ['mark-connection', 'mark-packet', 'mark-routing', 'change-dscp', 'passthrough', 'accept', 'drop', 'jump', 'log']
interface MangleEntry {
'.id': string
chain: string
action: string
'new-connection-mark': string
'new-packet-mark': string
'new-routing-mark': string
'src-address': string
'dst-address': string
protocol: string
'dst-port': string
'src-port': string
disabled: string
comment: string
[key: string]: string
}
interface MangleForm {
chain: string
action: string
'src-address': string
'dst-address': string
protocol: string
'dst-port': string
'new-connection-mark': string
'new-packet-mark': string
'new-routing-mark': string
comment: string
}
const EMPTY_FORM: MangleForm = {
chain: 'prerouting',
action: 'mark-connection',
'src-address': '',
'dst-address': '',
protocol: '',
'dst-port': '',
'new-connection-mark': '',
'new-packet-mark': '',
'new-routing-mark': '',
comment: '',
}
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// ManglePanel
// ---------------------------------------------------------------------------
export function ManglePanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const { entries, isLoading, error, refetch } = useConfigBrowse(
tenantId, deviceId, '/ip/firewall/mangle', { enabled: active },
)
const panel = useConfigPanel(tenantId, deviceId, 'mangle')
const [previewOpen, setPreviewOpen] = useState(false)
const [chainFilter, setChainFilter] = useState<ChainFilter>('all')
const typedEntries = entries as MangleEntry[]
const filtered = useMemo(() => {
if (chainFilter === 'all') return typedEntries
return typedEntries.filter((e) => e.chain === chainFilter)
}, [typedEntries, chainFilter])
if (isLoading) {
return <div className="flex items-center justify-center py-12 text-text-secondary text-sm">Loading mangle rules...</div>
}
if (error) {
return <div className="flex items-center justify-center py-12 text-error text-sm">Failed to load mangle rules. <button className="underline ml-1" onClick={() => refetch()}>Retry</button></div>
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<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})
</Button>
</div>
<div className="flex gap-1 p-1 rounded-lg bg-elevated">
{CHAINS.map((chain) => (
<button
key={chain}
onClick={() => setChainFilter(chain)}
className={cn(
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors capitalize',
chainFilter === chain ? 'bg-surface text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
)}
>
{chain}
</button>
))}
</div>
<MangleTable entries={filtered} panel={panel} />
<ChangePreviewModal open={previewOpen} onOpenChange={setPreviewOpen} changes={panel.pendingChanges} applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }} isApplying={panel.isApplying} />
</div>
)
}
// ---------------------------------------------------------------------------
// Mangle Table
// ---------------------------------------------------------------------------
function MangleTable({ entries, panel }: { entries: MangleEntry[]; panel: PanelHook }) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<MangleEntry | null>(null)
const [form, setForm] = useState<MangleForm>(EMPTY_FORM)
const handleAdd = useCallback(() => { setEditing(null); setForm(EMPTY_FORM); setDialogOpen(true) }, [])
const handleEdit = useCallback((e: MangleEntry) => {
setEditing(e)
setForm({
chain: e.chain || 'prerouting', action: e.action || 'mark-connection',
'src-address': e['src-address'] || '', 'dst-address': e['dst-address'] || '',
protocol: e.protocol || '', 'dst-port': e['dst-port'] || '',
'new-connection-mark': e['new-connection-mark'] || '',
'new-packet-mark': e['new-packet-mark'] || '',
'new-routing-mark': e['new-routing-mark'] || '',
comment: e.comment || '',
})
setDialogOpen(true)
}, [])
const handleDelete = useCallback((e: MangleEntry) => {
panel.addChange({ operation: 'remove', path: '/ip/firewall/mangle', entryId: e['.id'], properties: {}, description: `Remove mangle rule ${e.chain}/${e.action} ${e.comment || ''}`.trim() })
}, [panel])
const handleSave = useCallback(() => {
if (!form.chain || !form.action) return
const props: Record<string, string> = { chain: form.chain, action: form.action }
if (form['src-address']) props['src-address'] = form['src-address']
if (form['dst-address']) props['dst-address'] = form['dst-address']
if (form.protocol) props.protocol = form.protocol
if (form['dst-port']) props['dst-port'] = form['dst-port']
if (form['new-connection-mark']) props['new-connection-mark'] = form['new-connection-mark']
if (form['new-packet-mark']) props['new-packet-mark'] = form['new-packet-mark']
if (form['new-routing-mark']) props['new-routing-mark'] = form['new-routing-mark']
if (form.comment) props.comment = form.comment
if (editing) {
panel.addChange({ operation: 'set', path: '/ip/firewall/mangle', entryId: editing['.id'], properties: props, description: `Edit mangle ${form.chain}/${form.action}` })
} else {
panel.addChange({ operation: 'add', path: '/ip/firewall/mangle', properties: props, description: `Add mangle ${form.chain}/${form.action} ${form.comment || ''}`.trim() })
}
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Filter className="h-4 w-4" />
Mangle Rules ({entries.length})
</div>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}><Plus className="h-3.5 w-3.5" />Add Rule</Button>
</div>
{entries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">No mangle rules found.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Chain</th>
<th className="text-left px-4 py-2 font-medium">Src Address</th>
<th className="text-left px-4 py-2 font-medium">Dst Address</th>
<th className="text-left px-4 py-2 font-medium">Protocol</th>
<th className="text-left px-4 py-2 font-medium">Action</th>
<th className="text-left px-4 py-2 font-medium">Mark</th>
<th className="text-left px-4 py-2 font-medium">Status</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry['.id']} className={cn('border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors', entry.disabled === 'true' && 'opacity-50')}>
<td className="px-4 py-2 text-text-primary">{entry.chain}</td>
<td className="px-4 py-2 font-mono text-text-secondary text-xs">{entry['src-address'] || 'any'}</td>
<td className="px-4 py-2 font-mono text-text-secondary text-xs">{entry['dst-address'] || 'any'}</td>
<td className="px-4 py-2 text-text-secondary">{entry.protocol || 'any'}</td>
<td className="px-4 py-2"><span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-info/10 text-info border-info/40">{entry.action}</span></td>
<td className="px-4 py-2 text-text-muted text-xs">{entry['new-connection-mark'] || entry['new-packet-mark'] || entry['new-routing-mark'] || '—'}</td>
<td className="px-4 py-2">
{entry.disabled === 'true'
? <span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-error/10 text-error border-error/40">disabled</span>
: <span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">active</span>}
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => handleEdit(entry)} title="Edit"><Pencil className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-error hover:text-error" onClick={() => handleDelete(entry)} title="Delete"><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? 'Edit Mangle Rule' : 'Add Mangle Rule'}</DialogTitle>
<DialogDescription>Configure the mangle rule properties. Changes are staged.</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Chain</Label>
<Select value={form.chain} onValueChange={(v) => setForm((f) => ({ ...f, chain: v }))}>
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>{CHAINS.filter((c) => c !== 'all').map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Action</Label>
<Select value={form.action} onValueChange={(v) => setForm((f) => ({ ...f, action: v }))}>
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>{ACTIONS.map((a) => <SelectItem key={a} value={a}>{a}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Src Address</Label>
<Input value={form['src-address']} onChange={(e) => setForm((f) => ({ ...f, 'src-address': e.target.value }))} placeholder="any" className="h-8 text-sm font-mono" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Dst Address</Label>
<Input value={form['dst-address']} onChange={(e) => setForm((f) => ({ ...f, 'dst-address': e.target.value }))} placeholder="any" className="h-8 text-sm font-mono" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Protocol</Label>
<Input value={form.protocol} onChange={(e) => setForm((f) => ({ ...f, protocol: e.target.value }))} placeholder="tcp" className="h-8 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Dst Port</Label>
<Input value={form['dst-port']} onChange={(e) => setForm((f) => ({ ...f, 'dst-port': e.target.value }))} placeholder="80,443" className="h-8 text-sm font-mono" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Conn. Mark</Label>
<Input value={form['new-connection-mark']} onChange={(e) => setForm((f) => ({ ...f, 'new-connection-mark': e.target.value }))} className="h-8 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Packet Mark</Label>
<Input value={form['new-packet-mark']} onChange={(e) => setForm((f) => ({ ...f, 'new-packet-mark': e.target.value }))} className="h-8 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Routing Mark</Label>
<Input value={form['new-routing-mark']} onChange={(e) => setForm((f) => ({ ...f, 'new-routing-mark': e.target.value }))} className="h-8 text-sm" />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Comment</Label>
<Input value={form.comment} onChange={(e) => setForm((f) => ({ ...f, comment: e.target.value }))} placeholder="optional" className="h-8 text-sm" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>{editing ? 'Stage Edit' : 'Stage Rule'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,54 @@
/**
* NetworkToolsPanel -- Container for network diagnostic tools.
*
* Sub-tabs: Ping, Traceroute, Bandwidth Test, Torch.
* Each tool is an interactive command executor, not a CRUD panel.
*/
import { useState } from 'react'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
import { PingTool } from './PingTool'
import { TracerouteTool } from './TracerouteTool'
import { BandwidthTestTool } from './BandwidthTestTool'
import { TorchTool } from './TorchTool'
import { cn } from '@/lib/utils'
const TOOLS = [
{ id: 'ping', label: 'Ping' },
{ id: 'traceroute', label: 'Traceroute' },
{ id: 'bw-test', label: 'BW Test' },
{ id: 'torch', label: 'Torch' },
] as const
type ToolId = (typeof TOOLS)[number]['id']
export function NetworkToolsPanel(props: ConfigPanelProps) {
const [activeTool, setActiveTool] = useState<ToolId>('ping')
return (
<div className="space-y-4">
{/* Sub-tab selector */}
<div className="flex gap-1 border-b border-border">
{TOOLS.map((tool) => (
<button
key={tool.id}
onClick={() => setActiveTool(tool.id)}
className={cn(
'px-3 py-1.5 text-xs font-medium border-b-2 transition-colors',
activeTool === tool.id
? 'border-accent text-accent'
: 'border-transparent text-text-muted hover:text-text-secondary',
)}
>
{tool.label}
</button>
))}
</div>
{activeTool === 'ping' && <PingTool {...props} />}
{activeTool === 'traceroute' && <TracerouteTool {...props} />}
{activeTool === 'bw-test' && <BandwidthTestTool {...props} />}
{activeTool === 'torch' && <TorchTool {...props} />}
</div>
)
}

View File

@@ -0,0 +1,216 @@
/**
* PingTool -- Interactive ping from device to target.
*
* Uses /ping command via config editor execute.
* Displays RTT min/avg/max, packet loss, TTL.
* Configurable count, size, interface, src-address.
*/
import { useState, useCallback } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Play, Square, Activity } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { configEditorApi } from '@/lib/configEditorApi'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
interface PingResult {
host: string
seq: string
ttl: string
time: string
status: string
}
interface PingStats {
sent: number
received: number
loss: string
minRtt: string
avgRtt: string
maxRtt: string
}
export function PingTool({ tenantId, deviceId }: ConfigPanelProps) {
const [target, setTarget] = useState('')
const [count, setCount] = useState('4')
const [size, setSize] = useState('64')
const [srcAddress, setSrcAddress] = useState('')
const [iface, setIface] = useState('')
const [results, setResults] = useState<PingResult[]>([])
const [stats, setStats] = useState<PingStats | null>(null)
const pingMutation = useMutation({
mutationFn: async () => {
const parts = ['/ping', `address=${target}`, `count=${count}`]
if (size !== '64') parts.push(`size=${size}`)
if (srcAddress) parts.push(`src-address=${srcAddress}`)
if (iface) parts.push(`interface=${iface}`)
const command = parts.join(' ')
return configEditorApi.execute(tenantId, deviceId, command)
},
onSuccess: (resp) => {
if (!resp.success) {
setResults([])
setStats({ sent: 0, received: 0, loss: '100%', minRtt: '-', avgRtt: '-', maxRtt: '-' })
return
}
const rows: PingResult[] = resp.data.map((d) => ({
host: d['host'] || target,
seq: d['seq'] || d['#'] || '',
ttl: d['ttl'] || '',
time: d['time'] || '',
status: d['status'] || (d['time'] ? 'ok' : 'timeout'),
}))
setResults(rows)
// Calculate stats
const sent = rows.length
const received = rows.filter((r) => r.status !== 'timeout' && r.time).length
const rtts = rows.map((r) => parseFloat(r.time)).filter((v) => !isNaN(v))
setStats({
sent,
received,
loss: sent > 0 ? `${(((sent - received) / sent) * 100).toFixed(0)}%` : '0%',
minRtt: rtts.length > 0 ? `${Math.min(...rtts)}ms` : '-',
avgRtt: rtts.length > 0 ? `${(rtts.reduce((a, b) => a + b, 0) / rtts.length).toFixed(1)}ms` : '-',
maxRtt: rtts.length > 0 ? `${Math.max(...rtts)}ms` : '-',
})
},
})
const handleRun = useCallback(() => {
if (!target.trim()) return
setResults([])
setStats(null)
pingMutation.mutate()
}, [target, pingMutation])
return (
<div className="space-y-4">
{/* Input form */}
<div className="rounded-lg border border-border bg-surface p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="space-y-1 col-span-2">
<Label className="text-xs text-text-secondary">Target IP / Hostname</Label>
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="8.8.8.8"
className="h-8 text-sm font-mono"
onKeyDown={(e) => e.key === 'Enter' && handleRun()}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Count</Label>
<Input
type="number"
value={count}
onChange={(e) => setCount(e.target.value)}
min={1}
max={100}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Size (bytes)</Label>
<Input
type="number"
value={size}
onChange={(e) => setSize(e.target.value)}
min={28}
max={65535}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Src Address</Label>
<Input
value={srcAddress}
onChange={(e) => setSrcAddress(e.target.value)}
placeholder="optional"
className="h-8 text-sm font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Interface</Label>
<Input
value={iface}
onChange={(e) => setIface(e.target.value)}
placeholder="optional"
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex items-end col-span-2 sm:col-span-2">
<Button
onClick={handleRun}
disabled={!target.trim() || pingMutation.isPending}
className="gap-1.5 w-full sm:w-auto"
>
{pingMutation.isPending ? (
<><Square className="h-3.5 w-3.5" /> Running...</>
) : (
<><Play className="h-3.5 w-3.5" /> Ping</>
)}
</Button>
</div>
</div>
</div>
{/* Results */}
{pingMutation.isError && (
<div className="rounded-lg border border-error/50 bg-error/10 p-4 text-sm text-error">
Failed to execute ping command.
</div>
)}
{results.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Activity className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">Ping Results</span>
</div>
<div className="font-mono text-xs bg-elevated p-3 space-y-0.5 max-h-80 overflow-y-auto">
{results.map((r, i) => (
<div key={i} className={cn(
'flex gap-4 px-2 py-0.5 rounded',
r.status === 'timeout' ? 'text-error' : 'text-text-primary',
)}>
<span className="w-8 text-text-muted text-right">{r.seq || i + 1}</span>
<span className="w-32">{r.host}</span>
<span className="w-16">{r.ttl ? `ttl=${r.ttl}` : ''}</span>
<span className="w-20">{r.time ? `time=${r.time}ms` : 'timeout'}</span>
</div>
))}
</div>
</div>
)}
{/* Stats summary */}
{stats && (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-4 text-center">
<StatBox label="Sent" value={String(stats.sent)} />
<StatBox label="Received" value={String(stats.received)} />
<StatBox label="Loss" value={stats.loss} warn={stats.loss !== '0%'} />
<StatBox label="Min RTT" value={stats.minRtt} />
<StatBox label="Avg RTT" value={stats.avgRtt} />
<StatBox label="Max RTT" value={stats.maxRtt} />
</div>
</div>
)}
</div>
)
}
function StatBox({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
return (
<div>
<div className={cn('text-lg font-bold', warn ? 'text-error' : 'text-text-primary')}>
{value}
</div>
<div className="text-xs text-text-muted">{label}</div>
</div>
)
}

View File

@@ -0,0 +1,448 @@
/**
* PoolPanel -- IP pool management panel for device configuration.
*
* Displays IP pools from /ip/pool with range editor, next-pool chaining,
* used-by DHCP indicator, add/edit/delete dialogs,
* and standard apply mode by default.
*/
import { useState, useCallback, useMemo } from 'react'
import { Plus, Pencil, Trash2, Layers } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Entry & form types
// ---------------------------------------------------------------------------
interface PoolEntry {
'.id': string
name: string
ranges: string
'next-pool': string
[key: string]: string
}
interface DhcpServerEntry {
'.id': string
name: string
'address-pool': string
[key: string]: string
}
interface PoolForm {
name: string
ranges: string
'next-pool': string
}
const EMPTY_FORM: PoolForm = {
name: '',
ranges: '',
'next-pool': '',
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/
function validateRange(range: string): boolean {
const trimmed = range.trim()
if (!trimmed) return false
const parts = trimmed.split('-')
if (parts.length !== 2) return false
return IP_REGEX.test(parts[0].trim()) && IP_REGEX.test(parts[1].trim())
}
function validatePoolForm(form: PoolForm): Record<string, string> {
const errors: Record<string, string> = {}
if (!form.name) {
errors.name = 'Pool name is required'
}
if (!form.ranges) {
errors.ranges = 'At least one range is required'
} else {
const ranges = form.ranges.split(',')
const invalid = ranges.filter((r) => !validateRange(r))
if (invalid.length > 0) {
errors.ranges = 'Each range must be in format x.x.x.x-x.x.x.x'
}
}
return errors
}
// ---------------------------------------------------------------------------
// Panel type shorthand
// ---------------------------------------------------------------------------
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// PoolPanel
// ---------------------------------------------------------------------------
export function PoolPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const { entries, isLoading, error, refetch } = useConfigBrowse(
tenantId,
deviceId,
'/ip/pool',
{ enabled: active },
)
// Fetch DHCP servers to show used-by indicator
const { entries: dhcpEntries } = useConfigBrowse(
tenantId,
deviceId,
'/ip/dhcp-server',
{ enabled: active },
)
const panel = useConfigPanel(tenantId, deviceId, 'pools')
const [previewOpen, setPreviewOpen] = useState(false)
const typedEntries = entries as PoolEntry[]
const dhcpServers = dhcpEntries as DhcpServerEntry[]
// Build a map of pool name → DHCP server names that use it
const poolUsedBy = useMemo(() => {
const map: Record<string, string[]> = {}
dhcpServers.forEach((server) => {
const pool = server['address-pool']
if (pool) {
if (!map[pool]) map[pool] = []
map[pool].push(server.name || server['.id'])
}
})
return map
}, [dhcpServers])
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading IP pools...
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load IP pools.{' '}
<button className="underline ml-1" onClick={() => refetch()}>
Retry
</button>
</div>
)
}
return (
<div className="space-y-4">
{/* Header with SafetyToggle and Apply button */}
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* Pool table */}
<PoolTable
entries={typedEntries}
panel={panel}
poolUsedBy={poolUsedBy}
existingPools={typedEntries.map((e) => e.name).filter(Boolean)}
/>
{/* Change Preview Modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Pool Table
// ---------------------------------------------------------------------------
function PoolTable({
entries,
panel,
poolUsedBy,
existingPools,
}: {
entries: PoolEntry[]
panel: PanelHook
poolUsedBy: Record<string, string[]>
existingPools: string[]
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<PoolEntry | null>(null)
const [form, setForm] = useState<PoolForm>(EMPTY_FORM)
const [errors, setErrors] = useState<Record<string, string>>({})
const handleAdd = useCallback(() => {
setEditing(null)
setForm(EMPTY_FORM)
setErrors({})
setDialogOpen(true)
}, [])
const handleEdit = useCallback((entry: PoolEntry) => {
setEditing(entry)
setForm({
name: entry.name || '',
ranges: entry.ranges || '',
'next-pool': entry['next-pool'] || '',
})
setErrors({})
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(entry: PoolEntry) => {
panel.addChange({
operation: 'remove',
path: '/ip/pool',
entryId: entry['.id'],
properties: {},
description: `Remove pool "${entry.name}"`,
})
},
[panel],
)
const handleSave = useCallback(() => {
const validationErrors = validatePoolForm(form)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
const props: Record<string, string> = {
name: form.name,
ranges: form.ranges,
}
if (form['next-pool']) {
props['next-pool'] = form['next-pool']
}
if (editing) {
panel.addChange({
operation: 'set',
path: '/ip/pool',
entryId: editing['.id'],
properties: props,
description: `Edit pool "${form.name}" ranges ${form.ranges}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/ip/pool',
properties: props,
description: `Add pool "${form.name}" with ranges ${form.ranges}`,
})
}
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
{/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Layers className="h-4 w-4" />
IP Pools ({entries.length})
</div>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" />
Add Pool
</Button>
</div>
{entries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
No IP pools configured.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Name</th>
<th className="text-left px-4 py-2 font-medium">Ranges</th>
<th className="text-left px-4 py-2 font-medium">Next Pool</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const usedBy = poolUsedBy[entry.name]
return (
<tr
key={entry['.id']}
className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors"
>
<td className="px-4 py-2">
<div>
<span className="text-text-primary font-medium">
{entry.name || '—'}
</span>
{usedBy && usedBy.length > 0 && (
<div className="text-xs text-text-muted mt-0.5">
Used by: {usedBy.join(', ')}
</div>
)}
</div>
</td>
<td className="px-4 py-2 font-mono text-text-secondary">
{entry.ranges || '—'}
</td>
<td className="px-4 py-2 text-text-secondary">
{entry['next-pool'] || '—'}
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleEdit(entry)}
title="Edit pool"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-error hover:text-error"
onClick={() => handleDelete(entry)}
title="Delete pool"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
{/* Add/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? 'Edit Pool' : 'Add Pool'}</DialogTitle>
<DialogDescription>
{editing
? 'Modify the pool properties below.'
: 'Enter the pool details. Changes are staged until you apply them.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label htmlFor="pool-name" className="text-xs text-text-secondary">
Pool Name
</Label>
<Input
id="pool-name"
value={form.name}
onChange={(e) =>
setForm((f) => ({ ...f, name: e.target.value }))
}
placeholder="dhcp-pool1"
className={cn('h-8 text-sm', errors.name && 'border-error')}
/>
{errors.name && (
<p className="text-xs text-error">{errors.name}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="pool-ranges" className="text-xs text-text-secondary">
Ranges
</Label>
<Input
id="pool-ranges"
value={form.ranges}
onChange={(e) =>
setForm((f) => ({ ...f, ranges: e.target.value }))
}
placeholder="192.168.1.100-192.168.1.200"
className={cn('h-8 text-sm font-mono', errors.ranges && 'border-error')}
/>
{errors.ranges && (
<p className="text-xs text-error">{errors.ranges}</p>
)}
<p className="text-xs text-text-muted">
Use start-end notation. Comma-separate multiple ranges.
</p>
</div>
<div className="space-y-1">
<Label htmlFor="pool-next" className="text-xs text-text-secondary">
Next Pool
</Label>
<Input
id="pool-next"
value={form['next-pool']}
onChange={(e) =>
setForm((f) => ({ ...f, 'next-pool': e.target.value }))
}
placeholder="none"
className="h-8 text-sm"
/>
<p className="text-xs text-text-muted">
Leave empty if no chaining needed.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
{editing ? 'Stage Edit' : 'Stage Pool'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,310 @@
/**
* PppPanel -- PPP profiles, secrets, and active connections management.
*
* Sub-tabs:
* 1. Profiles -- PPP profiles with rate-limit, bridge assignment
* 2. Secrets -- PPP user secrets with service/profile/caller-id
* 3. Active -- Active PPP connections (read-only with disconnect)
*
* Safe apply mode by default.
*/
import { useState, useCallback } from 'react'
import { Plus, Pencil, Trash2, Users, Key, Activity, XCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { configEditorApi } from '@/lib/configEditorApi'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Sub-tab types
// ---------------------------------------------------------------------------
type SubTab = 'profiles' | 'secrets' | 'active'
const SUB_TABS: { key: SubTab; label: string; icon: React.ReactNode }[] = [
{ key: 'profiles', label: 'Profiles', icon: <Users className="h-3.5 w-3.5" /> },
{ key: 'secrets', label: 'Secrets', icon: <Key className="h-3.5 w-3.5" /> },
{ key: 'active', label: 'Active', icon: <Activity className="h-3.5 w-3.5" /> },
]
// ---------------------------------------------------------------------------
// Entry types
// ---------------------------------------------------------------------------
interface ProfileEntry { '.id': string; name: string; 'local-address': string; 'remote-address': string; 'dns-server': string; 'rate-limit': string; bridge: string; [key: string]: string }
interface SecretEntry { '.id': string; name: string; password: string; service: string; profile: string; 'caller-id': string; 'remote-address': string; disabled: string; [key: string]: string }
interface ActiveEntry { '.id': string; name: string; service: string; 'caller-id': string; address: string; uptime: string; [key: string]: string }
// ---------------------------------------------------------------------------
// Form types
// ---------------------------------------------------------------------------
interface ProfileForm { name: string; 'local-address': string; 'remote-address': string; 'dns-server': string; 'rate-limit': string; bridge: string }
interface SecretForm { name: string; password: string; service: string; profile: string; 'caller-id': string; 'remote-address': string }
const EMPTY_PROFILE: ProfileForm = { name: '', 'local-address': '', 'remote-address': '', 'dns-server': '', 'rate-limit': '', bridge: '' }
const EMPTY_SECRET: SecretForm = { name: '', password: '', service: 'any', profile: 'default', 'caller-id': '', 'remote-address': '' }
const PPP_SERVICES = ['any', 'async', 'l2tp', 'ovpn', 'pppoe', 'pptp', 'sstp']
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// PppPanel
// ---------------------------------------------------------------------------
export function PppPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const [activeTab, setActiveTab] = useState<SubTab>('profiles')
const profiles = useConfigBrowse(tenantId, deviceId, '/ppp/profile', { enabled: active })
const secrets = useConfigBrowse(tenantId, deviceId, '/ppp/secret', { enabled: active })
const activeConns = useConfigBrowse(tenantId, deviceId, '/ppp/active', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'ppp')
const [previewOpen, setPreviewOpen] = useState(false)
const isLoading = profiles.isLoading || secrets.isLoading || activeConns.isLoading
if (isLoading) {
return <div className="flex items-center justify-center py-12 text-text-secondary text-sm">Loading PPP configuration...</div>
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<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})
</Button>
</div>
<div className="flex gap-1 p-1 rounded-lg bg-elevated">
{SUB_TABS.map((tab) => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
className={cn('flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
activeTab === tab.key ? 'bg-surface text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-surface/50')}>
{tab.icon}{tab.label}
{tab.key === 'active' && activeConns.entries.length > 0 && (
<span className="text-xs bg-accent/20 text-accent px-1 rounded">{activeConns.entries.length}</span>
)}
</button>
))}
</div>
{activeTab === 'profiles' && <ProfilesTab entries={profiles.entries as ProfileEntry[]} panel={panel} />}
{activeTab === 'secrets' && <SecretsTab entries={secrets.entries as SecretEntry[]} panel={panel} profileNames={(profiles.entries as ProfileEntry[]).map((p) => p.name).filter(Boolean)} />}
{activeTab === 'active' && <ActiveTab entries={activeConns.entries as ActiveEntry[]} tenantId={tenantId} deviceId={deviceId} refetch={activeConns.refetch} />}
<ChangePreviewModal open={previewOpen} onOpenChange={setPreviewOpen} changes={panel.pendingChanges} applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }} isApplying={panel.isApplying} />
</div>
)
}
// ---------------------------------------------------------------------------
// Profiles Tab
// ---------------------------------------------------------------------------
function ProfilesTab({ entries, panel }: { entries: ProfileEntry[]; panel: PanelHook }) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<ProfileEntry | null>(null)
const [form, setForm] = useState<ProfileForm>(EMPTY_PROFILE)
const handleAdd = useCallback(() => { setEditing(null); setForm(EMPTY_PROFILE); setDialogOpen(true) }, [])
const handleEdit = useCallback((e: ProfileEntry) => {
setEditing(e); setForm({ name: e.name || '', 'local-address': e['local-address'] || '', 'remote-address': e['remote-address'] || '', 'dns-server': e['dns-server'] || '', 'rate-limit': e['rate-limit'] || '', bridge: e.bridge || '' }); setDialogOpen(true)
}, [])
const handleDelete = useCallback((e: ProfileEntry) => { panel.addChange({ operation: 'remove', path: '/ppp/profile', entryId: e['.id'], properties: {}, description: `Remove PPP profile "${e.name}"` }) }, [panel])
const handleSave = useCallback(() => {
if (!form.name) return
const props: Record<string, string> = { name: form.name }
if (form['local-address']) props['local-address'] = form['local-address']
if (form['remote-address']) props['remote-address'] = form['remote-address']
if (form['dns-server']) props['dns-server'] = form['dns-server']
if (form['rate-limit']) props['rate-limit'] = form['rate-limit']
if (form.bridge) props.bridge = form.bridge
if (editing) panel.addChange({ operation: 'set', path: '/ppp/profile', entryId: editing['.id'], properties: props, description: `Edit PPP profile "${form.name}"` })
else panel.addChange({ operation: 'add', path: '/ppp/profile', properties: props, description: `Add PPP profile "${form.name}"` })
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">PPP Profiles ({entries.length})</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}><Plus className="h-3.5 w-3.5" />Add Profile</Button>
</div>
{entries.length === 0 ? <div className="px-4 py-8 text-center text-sm text-text-muted">No profiles.</div> : (
<div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Name</th><th className="text-left px-4 py-2 font-medium">Local Addr</th><th className="text-left px-4 py-2 font-medium">Remote Addr</th>
<th className="text-left px-4 py-2 font-medium">DNS</th><th className="text-left px-4 py-2 font-medium">Rate Limit</th><th className="text-left px-4 py-2 font-medium">Bridge</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr></thead><tbody>
{entries.map((e) => <tr key={e['.id']} className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 text-text-primary font-medium">{e.name}</td>
<td className="px-4 py-2 font-mono text-text-secondary text-xs">{e['local-address'] || '—'}</td>
<td className="px-4 py-2 font-mono text-text-secondary text-xs">{e['remote-address'] || '—'}</td>
<td className="px-4 py-2 text-text-secondary text-xs">{e['dns-server'] || '—'}</td>
<td className="px-4 py-2 text-text-secondary text-xs">{e['rate-limit'] || '—'}</td>
<td className="px-4 py-2 text-text-secondary text-xs">{e.bridge || '—'}</td>
<td className="px-4 py-2"><div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => handleEdit(e)}><Pencil className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-error hover:text-error" onClick={() => handleDelete(e)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div></td>
</tr>)}
</tbody></table></div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}><DialogContent>
<DialogHeader><DialogTitle>{editing ? 'Edit Profile' : 'Add Profile'}</DialogTitle><DialogDescription>PPP profile settings.</DialogDescription></DialogHeader>
<div className="space-y-3 mt-2">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Name</Label><Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} className="h-8 text-sm" /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Local Address</Label><Input value={form['local-address']} onChange={(e) => setForm((f) => ({ ...f, 'local-address': e.target.value }))} placeholder="10.0.0.1" className="h-8 text-sm font-mono" /></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Remote Address</Label><Input value={form['remote-address']} onChange={(e) => setForm((f) => ({ ...f, 'remote-address': e.target.value }))} placeholder="pool-name" className="h-8 text-sm" /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">DNS Server</Label><Input value={form['dns-server']} onChange={(e) => setForm((f) => ({ ...f, 'dns-server': e.target.value }))} placeholder="8.8.8.8" className="h-8 text-sm font-mono" /></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Rate Limit</Label><Input value={form['rate-limit']} onChange={(e) => setForm((f) => ({ ...f, 'rate-limit': e.target.value }))} placeholder="10M/10M" className="h-8 text-sm" /></div>
</div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Bridge</Label><Input value={form.bridge} onChange={(e) => setForm((f) => ({ ...f, bridge: e.target.value }))} placeholder="none" className="h-8 text-sm" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={handleSave}>{editing ? 'Stage Edit' : 'Stage Profile'}</Button></DialogFooter>
</DialogContent></Dialog>
</>
)
}
// ---------------------------------------------------------------------------
// Secrets Tab
// ---------------------------------------------------------------------------
function SecretsTab({ entries, panel, profileNames }: { entries: SecretEntry[]; panel: PanelHook; profileNames: string[] }) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<SecretEntry | null>(null)
const [form, setForm] = useState<SecretForm>(EMPTY_SECRET)
const handleAdd = useCallback(() => { setEditing(null); setForm(EMPTY_SECRET); setDialogOpen(true) }, [])
const handleEdit = useCallback((e: SecretEntry) => {
setEditing(e); setForm({ name: e.name || '', password: '', service: e.service || 'any', profile: e.profile || 'default', 'caller-id': e['caller-id'] || '', 'remote-address': e['remote-address'] || '' }); setDialogOpen(true)
}, [])
const handleDelete = useCallback((e: SecretEntry) => { panel.addChange({ operation: 'remove', path: '/ppp/secret', entryId: e['.id'], properties: {}, description: `Remove PPP secret "${e.name}"` }) }, [panel])
const handleSave = useCallback(() => {
if (!form.name) return
const props: Record<string, string> = { name: form.name, service: form.service, profile: form.profile }
if (form.password) props.password = form.password
if (form['caller-id']) props['caller-id'] = form['caller-id']
if (form['remote-address']) props['remote-address'] = form['remote-address']
if (editing) panel.addChange({ operation: 'set', path: '/ppp/secret', entryId: editing['.id'], properties: props, description: `Edit PPP secret "${form.name}"` })
else panel.addChange({ operation: 'add', path: '/ppp/secret', properties: props, description: `Add PPP secret "${form.name}"` })
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">PPP Secrets ({entries.length})</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}><Plus className="h-3.5 w-3.5" />Add Secret</Button>
</div>
{entries.length === 0 ? <div className="px-4 py-8 text-center text-sm text-text-muted">No secrets.</div> : (
<div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Name</th><th className="text-left px-4 py-2 font-medium">Service</th><th className="text-left px-4 py-2 font-medium">Profile</th>
<th className="text-left px-4 py-2 font-medium">Caller ID</th><th className="text-left px-4 py-2 font-medium">Status</th><th className="text-right px-4 py-2 font-medium">Actions</th>
</tr></thead><tbody>
{entries.map((e) => <tr key={e['.id']} className={cn('border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors', e.disabled === 'true' && 'opacity-50')}>
<td className="px-4 py-2 text-text-primary font-medium">{e.name}</td>
<td className="px-4 py-2 text-text-secondary">{e.service || 'any'}</td>
<td className="px-4 py-2 text-text-secondary">{e.profile || '—'}</td>
<td className="px-4 py-2 font-mono text-text-muted text-xs">{e['caller-id'] || '—'}</td>
<td className="px-4 py-2">{e.disabled === 'true' ? <span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-error/10 text-error border-error/40">disabled</span> : <span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">active</span>}</td>
<td className="px-4 py-2"><div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => handleEdit(e)}><Pencil className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-error hover:text-error" onClick={() => handleDelete(e)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div></td>
</tr>)}
</tbody></table></div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}><DialogContent>
<DialogHeader><DialogTitle>{editing ? 'Edit Secret' : 'Add Secret'}</DialogTitle><DialogDescription>PPP user credentials and settings.</DialogDescription></DialogHeader>
<div className="space-y-3 mt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Username</Label><Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} className="h-8 text-sm" /></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Password</Label><Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder={editing ? 'unchanged' : 'required'} className="h-8 text-sm" /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Service</Label>
<select value={form.service} onChange={(e) => setForm((f) => ({ ...f, service: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
{PPP_SERVICES.map((s) => <option key={s} value={s}>{s}</option>)}
</select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Profile</Label><Input value={form.profile} onChange={(e) => setForm((f) => ({ ...f, profile: e.target.value }))} placeholder="default" className="h-8 text-sm" list="profile-names" />
<datalist id="profile-names">{profileNames.map((n) => <option key={n} value={n} />)}</datalist></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Caller ID</Label><Input value={form['caller-id']} onChange={(e) => setForm((f) => ({ ...f, 'caller-id': e.target.value }))} className="h-8 text-sm" /></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Remote Address</Label><Input value={form['remote-address']} onChange={(e) => setForm((f) => ({ ...f, 'remote-address': e.target.value }))} placeholder="from pool" className="h-8 text-sm font-mono" /></div>
</div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={handleSave}>{editing ? 'Stage Edit' : 'Stage Secret'}</Button></DialogFooter>
</DialogContent></Dialog>
</>
)
}
// ---------------------------------------------------------------------------
// Active Connections Tab
// ---------------------------------------------------------------------------
function ActiveTab({ entries, tenantId, deviceId, refetch }: { entries: ActiveEntry[]; tenantId: string; deviceId: string; refetch: () => void }) {
const disconnectMutation = useMutation({
mutationFn: (entryId: string) => configEditorApi.removeEntry(tenantId, deviceId, '/ppp/active', entryId),
onSuccess: () => { toast.success('Connection disconnected'); refetch() },
onError: (err: Error) => { toast.error('Failed to disconnect', { description: err.message }) },
})
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">Active Connections ({entries.length})</span>
</div>
{entries.length === 0 ? <div className="px-4 py-8 text-center text-sm text-text-muted">No active PPP connections.</div> : (
<div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Name</th><th className="text-left px-4 py-2 font-medium">Service</th>
<th className="text-left px-4 py-2 font-medium">Caller ID</th><th className="text-left px-4 py-2 font-medium">Address</th>
<th className="text-left px-4 py-2 font-medium">Uptime</th><th className="text-right px-4 py-2 font-medium">Actions</th>
</tr></thead><tbody>
{entries.map((e) => <tr key={e['.id']} className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 text-text-primary font-medium">{e.name}</td>
<td className="px-4 py-2 text-text-secondary">{e.service}</td>
<td className="px-4 py-2 font-mono text-text-muted text-xs">{e['caller-id'] || '—'}</td>
<td className="px-4 py-2 font-mono text-text-secondary">{e.address || '—'}</td>
<td className="px-4 py-2 text-text-secondary">{e.uptime || '—'}</td>
<td className="px-4 py-2 text-right">
<Button variant="ghost" size="sm" className="h-7 gap-1 text-xs text-error hover:text-error" onClick={() => disconnectMutation.mutate(e['.id'])} disabled={disconnectMutation.isPending}>
<XCircle className="h-3.5 w-3.5" />Disconnect
</Button>
</td>
</tr>)}
</tbody></table></div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { configApi } from '@/lib/api'
import { toast } from '@/components/ui/toast'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { RestorePreview } from './RestorePreview'
interface RestoreButtonProps {
tenantId: string
deviceId: string
commitSha: string
backupDate: string
deviceHostname: string
open: boolean
onOpenChange: (open: boolean) => void
onComplete: () => void
}
type RestorePhase = 'preview' | 'pushing' | 'verifying' | 'done'
export function RestoreButton({
tenantId,
deviceId,
commitSha,
backupDate,
deviceHostname,
open,
onOpenChange,
onComplete,
}: RestoreButtonProps) {
const [phase, setPhase] = useState<RestorePhase>('preview')
const handleRestore = async () => {
setPhase('pushing')
// Show verifying state after 30s (halfway through the 60s settle wait)
const verifyTimer = setTimeout(() => {
setPhase('verifying')
}, 30_000)
try {
const result = await configApi.restore(tenantId, deviceId, commitSha)
clearTimeout(verifyTimer)
setPhase('done')
if (result.status === 'committed') {
toast({
title: 'Config restored',
description: result.message,
})
onComplete()
onOpenChange(false)
} else if (result.status === 'reverted') {
toast({
title: 'Restore reverted',
description: result.message,
variant: 'destructive',
})
onComplete()
onOpenChange(false)
} else {
toast({
title: 'Restore failed',
description: result.message,
variant: 'destructive',
})
onOpenChange(false)
}
} catch (err: unknown) {
clearTimeout(verifyTimer)
setPhase('preview')
const message =
err instanceof Error ? err.message : 'Restore operation failed'
toast({
title: 'Restore failed',
description: message,
variant: 'destructive',
})
}
}
const isRunning = phase === 'pushing' || phase === 'verifying'
const handleOpenChange = (nextOpen: boolean) => {
if (isRunning) return
if (!nextOpen) setPhase('preview')
onOpenChange(nextOpen)
}
const statusText = () => {
switch (phase) {
case 'pushing':
return 'Pushing config to device...'
case 'verifying':
return 'Waiting for verification (~60s total)...'
default:
return null
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Restore Config</DialogTitle>
<DialogDescription>
This will push the config from{' '}
<span className="text-text-primary font-medium">{backupDate}</span> to{' '}
<span className="text-text-primary font-medium">{deviceHostname}</span>.
The system will create a safety backup and auto-revert if the device
becomes unreachable.
</DialogDescription>
</DialogHeader>
{phase === 'preview' && (
<RestorePreview
tenantId={tenantId}
deviceId={deviceId}
commitSha={commitSha}
onProceed={() => void handleRestore()}
onCancel={() => handleOpenChange(false)}
/>
)}
{isRunning && (
<div className="flex items-center gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm text-warning">
<Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
<span>{statusText()}</span>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,180 @@
/**
* RestorePreview — Shows impact analysis before executing a config restore.
* Three panels: summary bar, category breakdown with risk badges, warnings.
*/
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CheckCircle, ChevronDown, ChevronRight, Shield, XCircle } from 'lucide-react'
import { useState } from 'react'
import { configApi } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface RestorePreviewProps {
tenantId: string
deviceId: string
commitSha: string
onProceed: () => void
onCancel: () => void
isProceedDisabled?: boolean
}
const riskBadgeColors = {
none: 'bg-muted text-text-secondary',
low: 'bg-success/10 text-success border-success/30',
medium: 'bg-warning/10 text-warning border-warning/30',
high: 'bg-destructive/10 text-destructive border-destructive/30',
} as const
export function RestorePreview({
tenantId,
deviceId,
commitSha,
onProceed,
onCancel,
isProceedDisabled,
}: RestorePreviewProps) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const { data: preview, isLoading, error } = useQuery({
queryKey: ['restore-preview', tenantId, deviceId, commitSha],
queryFn: () => configApi.previewRestore(tenantId, deviceId, commitSha),
})
const togglePath = (path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev)
if (next.has(path)) next.delete(path)
else next.add(path)
return next
})
}
if (isLoading) {
return (
<div className="space-y-4 p-4">
<div className="h-12 rounded-lg bg-muted animate-pulse" />
<div className="h-32 rounded-lg bg-muted animate-pulse" />
<div className="h-16 rounded-lg bg-muted animate-pulse" />
</div>
)
}
if (error || !preview) {
return (
<div className="p-4 space-y-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 flex items-start gap-3">
<XCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-destructive">Preview failed</p>
<p className="text-xs text-text-secondary mt-1">
Could not analyze the config. You may still proceed manually.
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
<Button variant="destructive" size="sm" onClick={onProceed}>Proceed Anyway</Button>
</div>
</div>
)
}
const { diff, categories, warnings, validation } = preview
const hasHighRisk = categories.some((c) => c.risk === 'high')
const changedCategories = categories.filter((c) => c.adds > 0 || c.removes > 0)
return (
<div className="space-y-4 p-4">
{/* Validation errors */}
{!validation.valid && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 space-y-1">
<p className="text-sm font-medium text-destructive flex items-center gap-2">
<XCircle className="h-4 w-4" />
Validation errors found
</p>
{validation.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive/80 ml-6">{err}</p>
))}
</div>
)}
{/* Summary bar */}
<div className="rounded-lg border border-border bg-surface-raised p-3 flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<span className="text-success font-mono">+{diff.added}</span>
<span className="text-destructive font-mono">-{diff.removed}</span>
<span className="text-text-secondary">
across {changedCategories.length} categor{changedCategories.length === 1 ? 'y' : 'ies'}
</span>
</div>
{hasHighRisk ? (
<span className="text-xs font-medium text-destructive flex items-center gap-1">
<Shield className="h-3 w-3" /> High risk
</span>
) : (
<span className="text-xs font-medium text-success flex items-center gap-1">
<CheckCircle className="h-3 w-3" /> Low risk
</span>
)}
</div>
{/* Category breakdown */}
{changedCategories.length > 0 && (
<div className="rounded-lg border border-border divide-y divide-border">
{changedCategories.map((cat) => (
<button
key={cat.path}
className="w-full px-3 py-2 flex items-center justify-between hover:bg-surface-raised/50 transition-colors"
onClick={() => togglePath(cat.path)}
>
<div className="flex items-center gap-2 text-sm">
{expandedPaths.has(cat.path) ? (
<ChevronDown className="h-3 w-3 text-text-secondary" />
) : (
<ChevronRight className="h-3 w-3 text-text-secondary" />
)}
<code className="text-xs font-mono text-text-primary">{cat.path}</code>
</div>
<div className="flex items-center gap-3">
{cat.adds > 0 && <span className="text-xs text-success">+{cat.adds}</span>}
{cat.removes > 0 && <span className="text-xs text-destructive">-{cat.removes}</span>}
<span className={cn(
'text-xs px-1.5 py-0.5 rounded border',
riskBadgeColors[cat.risk],
)}>
{cat.risk}
</span>
</div>
</button>
))}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="rounded-lg border border-warning/30 bg-warning/5 p-3 space-y-2">
{warnings.map((w, i) => (
<p key={i} className="text-xs text-warning flex items-start gap-2">
<AlertTriangle className="h-3 w-3 shrink-0 mt-0.5" />
{w}
</p>
))}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
<Button
variant="destructive"
size="sm"
onClick={onProceed}
disabled={isProceedDisabled || !validation.valid}
>
{!validation.valid ? 'Cannot Restore (Invalid)' : 'Proceed with Restore'}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
/**
* RollbackAlert — Banner shown when a device went offline after a config push.
* Offers one-click emergency rollback.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, RotateCcw } from 'lucide-react'
import { configApi } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
interface RollbackAlertProps {
tenantId: string
deviceId: string
deviceStatus: string
/** Whether there's a recent push alert for this device */
hasRecentPushAlert: boolean
}
export function RollbackAlert({
tenantId,
deviceId,
deviceStatus,
hasRecentPushAlert,
}: RollbackAlertProps) {
const queryClient = useQueryClient()
const rollbackMutation = useMutation({
mutationFn: () => configApi.emergencyRollback(tenantId, deviceId),
onSuccess: () => {
toast.success('Emergency rollback successful')
queryClient.invalidateQueries({ queryKey: ['config-backups', tenantId, deviceId] })
},
onError: () => {
toast.error('Emergency rollback failed')
},
})
if (deviceStatus !== 'offline' || !hasRecentPushAlert) {
return null
}
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0" />
<div>
<p className="text-sm font-medium text-destructive">
Device went offline after config change
</p>
<p className="text-xs text-text-secondary mt-0.5">
A config change was made recently. You can rollback to the last known good config.
</p>
</div>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => rollbackMutation.mutate()}
disabled={rollbackMutation.isPending}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{rollbackMutation.isPending ? 'Rolling back...' : 'Rollback Now'}
</Button>
</div>
)
}

View File

@@ -0,0 +1,500 @@
/**
* RoutesPanel -- IP route management panel for device configuration.
*
* Displays routes from /ip/route with filter tabs (All/Static/Connected/Dynamic),
* add/edit/delete dialogs with CIDR validation, and safe apply mode by default.
*/
import { useState, useCallback, useMemo } from 'react'
import { Plus, Pencil, Trash2, Route, Filter } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Filter types
// ---------------------------------------------------------------------------
type FilterTab = 'all' | 'static' | 'connected' | 'dynamic'
const FILTER_TABS: { key: FilterTab; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'static', label: 'Static' },
{ key: 'connected', label: 'Connected' },
{ key: 'dynamic', label: 'Dynamic' },
]
// ---------------------------------------------------------------------------
// Entry & form types
// ---------------------------------------------------------------------------
interface RouteEntry {
'.id': string
'dst-address': string
gateway: string
distance: string
'routing-mark': string
interface: string
active: string
dynamic: string
static: string
connect: string
disabled: string
[key: string]: string
}
interface RouteForm {
'dst-address': string
gateway: string
distance: string
'routing-mark': string
}
const EMPTY_FORM: RouteForm = {
'dst-address': '',
gateway: '',
distance: '1',
'routing-mark': '',
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
const CIDR_REGEX = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/
function validateRouteForm(form: RouteForm): Record<string, string> {
const errors: Record<string, string> = {}
if (!form['dst-address']) {
errors['dst-address'] = 'Destination address is required'
} else if (!CIDR_REGEX.test(form['dst-address'])) {
errors['dst-address'] = 'Must be valid CIDR (e.g. 10.0.0.0/24)'
}
if (!form.gateway) {
errors.gateway = 'Gateway is required'
}
return errors
}
// ---------------------------------------------------------------------------
// Panel type shorthand
// ---------------------------------------------------------------------------
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// RoutesPanel
// ---------------------------------------------------------------------------
export function RoutesPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const { entries, isLoading, error, refetch } = useConfigBrowse(
tenantId,
deviceId,
'/ip/route',
{ enabled: active },
)
const panel = useConfigPanel(tenantId, deviceId, 'routes')
const [previewOpen, setPreviewOpen] = useState(false)
const [filterTab, setFilterTab] = useState<FilterTab>('all')
const typedEntries = entries as RouteEntry[]
const filteredEntries = useMemo(() => {
switch (filterTab) {
case 'static':
return typedEntries.filter(
(e) => e.static === 'true' && e.dynamic !== 'true',
)
case 'connected':
return typedEntries.filter((e) => e.connect === 'true')
case 'dynamic':
return typedEntries.filter((e) => e.dynamic === 'true')
default:
return typedEntries
}
}, [typedEntries, filterTab])
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading IP routes...
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load IP routes.{' '}
<button className="underline ml-1" onClick={() => refetch()}>
Retry
</button>
</div>
)
}
return (
<div className="space-y-4">
{/* Header with SafetyToggle and Apply button */}
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* Filter tabs */}
<div className="flex gap-1 p-1 rounded-lg bg-elevated">
{FILTER_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setFilterTab(tab.key)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
filterTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
)}
>
{tab.label}
</button>
))}
</div>
{/* Routes table */}
<RoutesTable entries={filteredEntries} panel={panel} />
{/* Change Preview Modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Status Badge
// ---------------------------------------------------------------------------
function RouteStatusBadge({ entry }: { entry: RouteEntry }) {
if (entry.disabled === 'true') {
return (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-error/10 text-error border-error/40">
disabled
</span>
)
}
if (entry.active === 'true') {
return (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">
active
</span>
)
}
return (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-elevated text-text-muted border-border">
inactive
</span>
)
}
// ---------------------------------------------------------------------------
// Routes Table
// ---------------------------------------------------------------------------
function RoutesTable({
entries,
panel,
}: {
entries: RouteEntry[]
panel: PanelHook
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<RouteEntry | null>(null)
const [form, setForm] = useState<RouteForm>(EMPTY_FORM)
const [errors, setErrors] = useState<Record<string, string>>({})
const handleAdd = useCallback(() => {
setEditing(null)
setForm(EMPTY_FORM)
setErrors({})
setDialogOpen(true)
}, [])
const handleEdit = useCallback((entry: RouteEntry) => {
setEditing(entry)
setForm({
'dst-address': entry['dst-address'] || '',
gateway: entry.gateway || '',
distance: entry.distance || '1',
'routing-mark': entry['routing-mark'] || '',
})
setErrors({})
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(entry: RouteEntry) => {
panel.addChange({
operation: 'remove',
path: '/ip/route',
entryId: entry['.id'],
properties: {},
description: `Remove route ${entry['dst-address']} via ${entry.gateway || 'connected'}`,
})
},
[panel],
)
const handleSave = useCallback(() => {
const validationErrors = validateRouteForm(form)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
const props: Record<string, string> = {
'dst-address': form['dst-address'],
gateway: form.gateway,
}
if (form.distance && form.distance !== '1') {
props.distance = form.distance
}
if (form['routing-mark']) {
props['routing-mark'] = form['routing-mark']
}
if (editing) {
panel.addChange({
operation: 'set',
path: '/ip/route',
entryId: editing['.id'],
properties: props,
description: `Edit route ${form['dst-address']} via ${form.gateway}`,
})
} else {
panel.addChange({
operation: 'add',
path: '/ip/route',
properties: props,
description: `Add route ${form['dst-address']} via ${form.gateway}`,
})
}
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
{/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Route className="h-4 w-4" />
IP Routes ({entries.length})
</div>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" />
Add Route
</Button>
</div>
{entries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
No routes found.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Dst. Address</th>
<th className="text-left px-4 py-2 font-medium">Gateway</th>
<th className="text-left px-4 py-2 font-medium">Distance</th>
<th className="text-left px-4 py-2 font-medium">Routing Mark</th>
<th className="text-left px-4 py-2 font-medium">Interface</th>
<th className="text-left px-4 py-2 font-medium">Status</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry['.id']}
className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors"
>
<td className="px-4 py-2 font-mono text-text-primary">
{entry['dst-address'] || '—'}
</td>
<td className="px-4 py-2 font-mono text-text-secondary">
{entry.gateway || '—'}
</td>
<td className="px-4 py-2 text-text-secondary">
{entry.distance || '—'}
</td>
<td className="px-4 py-2 text-text-secondary">
{entry['routing-mark'] || '—'}
</td>
<td className="px-4 py-2 text-text-secondary">
{entry.interface || '—'}
</td>
<td className="px-4 py-2">
<RouteStatusBadge entry={entry} />
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
{entry.dynamic !== 'true' && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleEdit(entry)}
title="Edit route"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-error hover:text-error"
onClick={() => handleDelete(entry)}
title="Delete route"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Add/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? 'Edit Route' : 'Add Route'}</DialogTitle>
<DialogDescription>
{editing
? 'Modify the route properties below.'
: 'Enter the route details. Changes are staged until you apply them.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label htmlFor="dst-address" className="text-xs text-text-secondary">
Destination Address (CIDR)
</Label>
<Input
id="dst-address"
value={form['dst-address']}
onChange={(e) =>
setForm((f) => ({ ...f, 'dst-address': e.target.value }))
}
placeholder="10.0.0.0/24"
className={cn('h-8 text-sm font-mono', errors['dst-address'] && 'border-error')}
/>
{errors['dst-address'] && (
<p className="text-xs text-error">{errors['dst-address']}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="gateway" className="text-xs text-text-secondary">
Gateway
</Label>
<Input
id="gateway"
value={form.gateway}
onChange={(e) =>
setForm((f) => ({ ...f, gateway: e.target.value }))
}
placeholder="192.168.1.1 or ether1"
className={cn('h-8 text-sm font-mono', errors.gateway && 'border-error')}
/>
{errors.gateway && (
<p className="text-xs text-error">{errors.gateway}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="distance" className="text-xs text-text-secondary">
Distance
</Label>
<Input
id="distance"
type="number"
value={form.distance}
onChange={(e) =>
setForm((f) => ({ ...f, distance: e.target.value }))
}
placeholder="1"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="routing-mark" className="text-xs text-text-secondary">
Routing Mark
</Label>
<Input
id="routing-mark"
value={form['routing-mark']}
onChange={(e) =>
setForm((f) => ({ ...f, 'routing-mark': e.target.value }))
}
placeholder="optional"
className="h-8 text-sm"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
{editing ? 'Stage Edit' : 'Stage Route'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,57 @@
/**
* SafetyToggle -- Toggle between Standard Apply and Safe Apply modes.
*
* Standard Apply: direct add/set/remove commands, no automatic rollback.
* Safe Apply (with auto-revert): generates RSC script, executed via two-phase mechanism.
*/
import { Zap, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { ApplyMode } from '@/lib/configPanelTypes'
interface SafetyToggleProps {
mode: ApplyMode
onModeChange: (mode: ApplyMode) => void
}
const MODE_DESCRIPTIONS: Record<ApplyMode, string> = {
quick: 'Changes applied directly. No automatic rollback.',
safe: 'Changes applied via RSC script with automatic revert if not confirmed.',
}
export function SafetyToggle({ mode, onModeChange }: SafetyToggleProps) {
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onModeChange('quick')}
className={cn(
'gap-1.5',
mode === 'quick' &&
'bg-accent/20 text-accent border-accent/40 hover:bg-accent/30 hover:text-accent',
)}
>
<Zap className="h-3.5 w-3.5" />
Standard Apply
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onModeChange('safe')}
className={cn(
'gap-1.5',
mode === 'safe' &&
'bg-accent/20 text-accent border-accent/40 hover:bg-accent/30 hover:text-accent',
)}
>
<ShieldCheck className="h-3.5 w-3.5" />
Safe Apply (with auto-revert)
</Button>
</div>
<p className="text-xs text-text-secondary">{MODE_DESCRIPTIONS[mode]}</p>
</div>
)
}

View File

@@ -0,0 +1,604 @@
/**
* ScriptsPanel -- Scripts & scheduler management panel.
*
* Sub-tabs:
* 1. Scripts -- view/add/edit/delete scripts with monospace editor and run button
* 2. Scheduler -- view/add/edit/delete scheduler entries
*
* Standard apply mode by default.
*/
import { useState, useCallback } from 'react'
import { Plus, Pencil, Trash2, Play, Code, Clock } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { configEditorApi } from '@/lib/configEditorApi'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Sub-tab types
// ---------------------------------------------------------------------------
type SubTab = 'scripts' | 'scheduler'
const SUB_TABS: { key: SubTab; label: string; icon: React.ReactNode }[] = [
{ key: 'scripts', label: 'Scripts', icon: <Code className="h-3.5 w-3.5" /> },
{ key: 'scheduler', label: 'Scheduler', icon: <Clock className="h-3.5 w-3.5" /> },
]
// ---------------------------------------------------------------------------
// Entry types
// ---------------------------------------------------------------------------
interface ScriptEntry {
'.id': string
name: string
source: string
owner: string
'last-started': string
'run-count': string
[key: string]: string
}
interface SchedulerEntry {
'.id': string
name: string
'start-time': string
interval: string
'on-event': string
disabled: string
'next-run': string
[key: string]: string
}
// ---------------------------------------------------------------------------
// Form types
// ---------------------------------------------------------------------------
interface ScriptForm {
name: string
source: string
}
interface SchedulerForm {
name: string
'start-time': string
interval: string
'on-event': string
}
const EMPTY_SCRIPT: ScriptForm = { name: '', source: '' }
const EMPTY_SCHEDULER: SchedulerForm = { name: '', 'start-time': 'startup', interval: '00:00:00', 'on-event': '' }
// ---------------------------------------------------------------------------
// Panel type shorthand
// ---------------------------------------------------------------------------
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// ScriptsPanel
// ---------------------------------------------------------------------------
export function ScriptsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const [activeTab, setActiveTab] = useState<SubTab>('scripts')
const scripts = useConfigBrowse(tenantId, deviceId, '/system/script', { enabled: active })
const scheduler = useConfigBrowse(tenantId, deviceId, '/system/scheduler', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'scripts')
const [previewOpen, setPreviewOpen] = useState(false)
const isLoading = scripts.isLoading || scheduler.isLoading
const hasError = scripts.error || scheduler.error
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading scripts & scheduler...
</div>
)
}
if (hasError) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load scripts.{' '}
<button className="underline ml-1" onClick={() => { scripts.refetch(); scheduler.refetch() }}>
Retry
</button>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* Sub-tab navigation */}
<div className="flex gap-1 p-1 rounded-lg bg-elevated">
{SUB_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
activeTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{activeTab === 'scripts' && (
<ScriptsTab
entries={scripts.entries as ScriptEntry[]}
panel={panel}
tenantId={tenantId}
deviceId={deviceId}
/>
)}
{activeTab === 'scheduler' && (
<SchedulerTab
entries={scheduler.entries as SchedulerEntry[]}
panel={panel}
/>
)}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Scripts Tab
// ---------------------------------------------------------------------------
function ScriptsTab({
entries,
panel,
tenantId,
deviceId,
}: {
entries: ScriptEntry[]
panel: PanelHook
tenantId: string
deviceId: string
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<ScriptEntry | null>(null)
const [form, setForm] = useState<ScriptForm>(EMPTY_SCRIPT)
const [errors, setErrors] = useState<Record<string, string>>({})
const runMutation = useMutation({
mutationFn: (scriptName: string) =>
configEditorApi.execute(tenantId, deviceId, `/system script run ${scriptName}`),
onSuccess: (_data, scriptName) => {
toast.success(`Script "${scriptName}" executed`)
},
onError: (err: Error, scriptName) => {
toast.error(`Failed to run "${scriptName}"`, { description: err.message })
},
})
const handleAdd = useCallback(() => {
setEditing(null)
setForm(EMPTY_SCRIPT)
setErrors({})
setDialogOpen(true)
}, [])
const handleEdit = useCallback((entry: ScriptEntry) => {
setEditing(entry)
setForm({ name: entry.name || '', source: entry.source || '' })
setErrors({})
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(entry: ScriptEntry) => {
panel.addChange({
operation: 'remove',
path: '/system/script',
entryId: entry['.id'],
properties: {},
description: `Remove script "${entry.name}"`,
})
},
[panel],
)
const handleSave = useCallback(() => {
const errs: Record<string, string> = {}
if (!form.name) errs.name = 'Script name is required'
if (!form.source) errs.source = 'Script source is required'
if (Object.keys(errs).length > 0) { setErrors(errs); return }
const props: Record<string, string> = { name: form.name, source: form.source }
if (editing) {
panel.addChange({
operation: 'set',
path: '/system/script',
entryId: editing['.id'],
properties: props,
description: `Edit script "${form.name}"`,
})
} else {
panel.addChange({
operation: 'add',
path: '/system/script',
properties: props,
description: `Add script "${form.name}"`,
})
}
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Code className="h-4 w-4" />
Scripts ({entries.length})
</div>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" />
Add Script
</Button>
</div>
{entries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">No scripts found.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Name</th>
<th className="text-left px-4 py-2 font-medium">Owner</th>
<th className="text-left px-4 py-2 font-medium">Last Run</th>
<th className="text-left px-4 py-2 font-medium">Run Count</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry['.id']} className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 text-text-primary font-medium">{entry.name || '—'}</td>
<td className="px-4 py-2 text-text-secondary">{entry.owner || '—'}</td>
<td className="px-4 py-2 text-text-muted text-xs">{entry['last-started'] || '—'}</td>
<td className="px-4 py-2 text-text-secondary">{entry['run-count'] || '0'}</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => runMutation.mutate(entry.name)}
disabled={runMutation.isPending}
title="Run script"
>
<Play className="h-3 w-3" />
Run
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => handleEdit(entry)} title="Edit">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-error hover:text-error" onClick={() => handleDelete(entry)} title="Delete">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editing ? 'Edit Script' : 'Add Script'}</DialogTitle>
<DialogDescription>
{editing ? 'Modify the script. Changes are staged until you apply.' : 'Create a new RouterOS script.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Script Name</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="my-script"
className={cn('h-8 text-sm', errors.name && 'border-error')}
/>
{errors.name && <p className="text-xs text-error">{errors.name}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Source</Label>
<textarea
value={form.source}
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
placeholder=":log info &quot;Hello from script&quot;"
rows={10}
className={cn(
'w-full rounded-md border bg-elevated px-3 py-2 text-sm font-mono text-text-primary resize-y',
errors.source ? 'border-error' : 'border-border',
)}
/>
{errors.source && <p className="text-xs text-error">{errors.source}</p>}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>{editing ? 'Stage Edit' : 'Stage Script'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
// ---------------------------------------------------------------------------
// Scheduler Tab
// ---------------------------------------------------------------------------
function SchedulerTab({
entries,
panel,
}: {
entries: SchedulerEntry[]
panel: PanelHook
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<SchedulerEntry | null>(null)
const [form, setForm] = useState<SchedulerForm>(EMPTY_SCHEDULER)
const [errors, setErrors] = useState<Record<string, string>>({})
const handleAdd = useCallback(() => {
setEditing(null)
setForm(EMPTY_SCHEDULER)
setErrors({})
setDialogOpen(true)
}, [])
const handleEdit = useCallback((entry: SchedulerEntry) => {
setEditing(entry)
setForm({
name: entry.name || '',
'start-time': entry['start-time'] || 'startup',
interval: entry.interval || '00:00:00',
'on-event': entry['on-event'] || '',
})
setErrors({})
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(entry: SchedulerEntry) => {
panel.addChange({
operation: 'remove',
path: '/system/scheduler',
entryId: entry['.id'],
properties: {},
description: `Remove scheduler "${entry.name}"`,
})
},
[panel],
)
const handleToggle = useCallback(
(entry: SchedulerEntry) => {
const newState = entry.disabled === 'true' ? 'false' : 'true'
panel.addChange({
operation: 'set',
path: '/system/scheduler',
entryId: entry['.id'],
properties: { disabled: newState },
description: `${newState === 'true' ? 'Disable' : 'Enable'} scheduler "${entry.name}"`,
})
},
[panel],
)
const handleSave = useCallback(() => {
const errs: Record<string, string> = {}
if (!form.name) errs.name = 'Name is required'
if (!form['on-event']) errs['on-event'] = 'On-event script is required'
if (Object.keys(errs).length > 0) { setErrors(errs); return }
const props: Record<string, string> = {
name: form.name,
'start-time': form['start-time'],
interval: form.interval,
'on-event': form['on-event'],
}
if (editing) {
panel.addChange({
operation: 'set',
path: '/system/scheduler',
entryId: editing['.id'],
properties: props,
description: `Edit scheduler "${form.name}"`,
})
} else {
panel.addChange({
operation: 'add',
path: '/system/scheduler',
properties: props,
description: `Add scheduler "${form.name}" (interval: ${form.interval})`,
})
}
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Clock className="h-4 w-4" />
Scheduler ({entries.length})
</div>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" />
Add Entry
</Button>
</div>
{entries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">No scheduler entries found.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Name</th>
<th className="text-left px-4 py-2 font-medium">Start Time</th>
<th className="text-left px-4 py-2 font-medium">Interval</th>
<th className="text-left px-4 py-2 font-medium">On Event</th>
<th className="text-left px-4 py-2 font-medium">Next Run</th>
<th className="text-left px-4 py-2 font-medium">Status</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry['.id']} className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 text-text-primary font-medium">{entry.name || '—'}</td>
<td className="px-4 py-2 text-text-secondary">{entry['start-time'] || '—'}</td>
<td className="px-4 py-2 font-mono text-text-secondary">{entry.interval || '—'}</td>
<td className="px-4 py-2 text-text-secondary text-xs max-w-[200px] truncate" title={entry['on-event']}>
{entry['on-event'] || '—'}
</td>
<td className="px-4 py-2 text-text-muted text-xs">{entry['next-run'] || '—'}</td>
<td className="px-4 py-2">
{entry.disabled === 'true' ? (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-error/10 text-error border-error/40">disabled</span>
) : (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">active</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => handleToggle(entry)}>
{entry.disabled === 'true' ? 'Enable' : 'Disable'}
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => handleEdit(entry)} title="Edit">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-error hover:text-error" onClick={() => handleDelete(entry)} title="Delete">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? 'Edit Scheduler' : 'Add Scheduler Entry'}</DialogTitle>
<DialogDescription>
{editing ? 'Modify the scheduler entry.' : 'Create a new scheduler entry. Changes are staged until you apply.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Name</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="daily-backup"
className={cn('h-8 text-sm', errors.name && 'border-error')}
/>
{errors.name && <p className="text-xs text-error">{errors.name}</p>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Start Time</Label>
<Input
value={form['start-time']}
onChange={(e) => setForm((f) => ({ ...f, 'start-time': e.target.value }))}
placeholder="startup"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Interval</Label>
<Input
value={form.interval}
onChange={(e) => setForm((f) => ({ ...f, interval: e.target.value }))}
placeholder="1d 00:00:00"
className="h-8 text-sm font-mono"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">On Event (script name or commands)</Label>
<Input
value={form['on-event']}
onChange={(e) => setForm((f) => ({ ...f, 'on-event': e.target.value }))}
placeholder="backup-script"
className={cn('h-8 text-sm', errors['on-event'] && 'border-error')}
/>
{errors['on-event'] && <p className="text-xs text-error">{errors['on-event']}</p>}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>{editing ? 'Stage Edit' : 'Stage Entry'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,355 @@
/**
* ServicesPanel -- IP services management panel.
*
* View/enable/disable RouterOS services (/ip/service),
* edit port numbers and allowed addresses.
* Security status indicators. Safe apply mode by default.
*/
import { useState, useCallback } from 'react'
import { Pencil, Shield, ShieldAlert, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ServiceEntry {
'.id': string
name: string
port: string
address: string
disabled: string
[key: string]: string
}
interface ServiceForm {
port: string
address: string
disabled: string
}
// Default ports for security indicators
const DEFAULT_PORTS: Record<string, number> = {
api: 8728,
'api-ssl': 8729,
ftp: 21,
ssh: 22,
telnet: 23,
winbox: 8291,
www: 80,
'www-ssl': 443,
}
// ---------------------------------------------------------------------------
// Panel type shorthand
// ---------------------------------------------------------------------------
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// ServicesPanel
// ---------------------------------------------------------------------------
export function ServicesPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const { entries, isLoading, error, refetch } = useConfigBrowse(
tenantId,
deviceId,
'/ip/service',
{ enabled: active },
)
const panel = useConfigPanel(tenantId, deviceId, 'services')
const [previewOpen, setPreviewOpen] = useState(false)
const typedEntries = entries as ServiceEntry[]
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading IP services...
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load services.{' '}
<button className="underline ml-1" onClick={() => refetch()}>Retry</button>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<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})
</Button>
</div>
<ServiceTable entries={typedEntries} panel={panel} />
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Security indicator
// ---------------------------------------------------------------------------
function SecurityIndicator({ entry }: { entry: ServiceEntry }) {
if (entry.disabled === 'true') {
return (
<div className="flex items-center gap-1.5">
<ShieldCheck className="h-3.5 w-3.5 text-success" />
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">
disabled
</span>
</div>
)
}
const port = parseInt(entry.port, 10)
const defaultPort = DEFAULT_PORTS[entry.name]
const isDefaultPort = defaultPort && port === defaultPort
const hasRestriction = entry.address && entry.address !== '' && entry.address !== '0.0.0.0/0'
if (isDefaultPort && !hasRestriction) {
return (
<div className="flex items-center gap-1.5">
<ShieldAlert className="h-3.5 w-3.5 text-warning" />
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-warning/10 text-warning border-warning/40">
default port
</span>
</div>
)
}
return (
<div className="flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5 text-info" />
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-info/10 text-info border-info/40">
enabled
</span>
</div>
)
}
// ---------------------------------------------------------------------------
// Service Table
// ---------------------------------------------------------------------------
function ServiceTable({
entries,
panel,
}: {
entries: ServiceEntry[]
panel: PanelHook
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<ServiceEntry | null>(null)
const [form, setForm] = useState<ServiceForm>({ port: '', address: '', disabled: 'false' })
const handleEdit = useCallback((entry: ServiceEntry) => {
setEditing(entry)
setForm({
port: entry.port || '',
address: entry.address || '',
disabled: entry.disabled || 'false',
})
setDialogOpen(true)
}, [])
const handleToggle = useCallback(
(entry: ServiceEntry) => {
const newState = entry.disabled === 'true' ? 'false' : 'true'
panel.addChange({
operation: 'set',
path: '/ip/service',
entryId: entry['.id'],
properties: { disabled: newState },
description: `${newState === 'true' ? 'Disable' : 'Enable'} service "${entry.name}"`,
})
},
[panel],
)
const handleSave = useCallback(() => {
if (!editing) return
const props: Record<string, string> = {}
if (form.port && form.port !== editing.port) props.port = form.port
if (form.address !== editing.address) props.address = form.address
if (form.disabled !== editing.disabled) props.disabled = form.disabled
if (Object.keys(props).length === 0) {
setDialogOpen(false)
return
}
panel.addChange({
operation: 'set',
path: '/ip/service',
entryId: editing['.id'],
properties: props,
description: `Edit service "${editing.name}" (${Object.entries(props).map(([k, v]) => `${k}=${v}`).join(', ')})`,
})
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Shield className="h-4 w-4" />
IP Services ({entries.length})
</div>
</div>
{entries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">No services found.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Service</th>
<th className="text-left px-4 py-2 font-medium">Port</th>
<th className="text-left px-4 py-2 font-medium">Allowed From</th>
<th className="text-left px-4 py-2 font-medium">Security</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry['.id']}
className={cn(
'border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors',
entry.disabled === 'true' && 'opacity-50',
)}
>
<td className="px-4 py-2 text-text-primary font-medium">
{entry.name || '—'}
</td>
<td className="px-4 py-2 font-mono text-text-secondary">
{entry.port || '—'}
</td>
<td className="px-4 py-2 font-mono text-text-secondary text-xs">
{entry.address || 'any'}
</td>
<td className="px-4 py-2">
<SecurityIndicator entry={entry} />
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => handleToggle(entry)}
>
{entry.disabled === 'true' ? 'Enable' : 'Disable'}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleEdit(entry)}
title="Edit service"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Service: {editing?.name}</DialogTitle>
<DialogDescription>
Modify port and access restrictions. Changes are staged until you apply.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Port</Label>
<Input
type="number"
value={form.port}
onChange={(e) => setForm((f) => ({ ...f, port: e.target.value }))}
className="h-8 text-sm font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Allowed Addresses</Label>
<Input
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
placeholder="0.0.0.0/0 (any)"
className="h-8 text-sm font-mono"
/>
<p className="text-xs text-text-muted">
Comma-separated CIDRs. Empty = allow from any.
</p>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Status</Label>
<select
value={form.disabled}
onChange={(e) => setForm((f) => ({ ...f, disabled: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
<option value="false">Enabled</option>
<option value="true">Disabled</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>Stage Change</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,360 @@
/**
* SnmpPanel -- SNMP configuration panel.
*
* Enable/disable SNMP (/snmp).
* Community strings management (/snmp/community).
* Trap target configuration.
* Contact, location, engine-id settings.
*/
import { useState, useCallback } from 'react'
import { Plus, Pencil, Trash2, Radio } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const snmp = useConfigBrowse(tenantId, deviceId, '/snmp', { enabled: active })
const communities = useConfigBrowse(tenantId, deviceId, '/snmp/community', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'snmp')
const [previewOpen, setPreviewOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [communityOpen, setCommunityOpen] = useState(false)
const [editEntry, setEditEntry] = useState<Record<string, string> | null>(null)
const [settingsForm, setSettingsForm] = useState<Record<string, string>>({})
const [communityForm, setCommunityForm] = useState<Record<string, string>>({})
const snmpData = snmp.entries[0] ?? {}
const isEnabled = snmpData['enabled'] === 'true' || snmpData['enabled'] === 'yes'
const handleToggleSnmp = useCallback(() => {
panel.addChange({
operation: 'set',
path: '/snmp',
properties: { enabled: isEnabled ? 'no' : 'yes' },
description: `${isEnabled ? 'Disable' : 'Enable'} SNMP`,
})
}, [isEnabled, panel])
const handleEditSettings = useCallback(() => {
setSettingsForm({
contact: snmpData['contact'] || '',
location: snmpData['location'] || '',
'engine-id': snmpData['engine-id'] || '',
'trap-target': snmpData['trap-target'] || '',
'trap-community': snmpData['trap-community'] || '',
'trap-version': snmpData['trap-version'] || '1',
})
setSettingsOpen(true)
}, [snmpData])
const handleSaveSettings = useCallback(() => {
const props: Record<string, string> = {}
Object.entries(settingsForm).forEach(([key, value]) => {
if (value !== (snmpData[key] || '')) props[key] = value
})
if (Object.keys(props).length === 0) { setSettingsOpen(false); return }
panel.addChange({
operation: 'set',
path: '/snmp',
properties: props,
description: `Update SNMP settings (${Object.keys(props).join(', ')})`,
})
setSettingsOpen(false)
}, [settingsForm, snmpData, panel])
const handleAddCommunity = useCallback(() => {
setEditEntry(null)
setCommunityForm({
name: '',
addresses: '0.0.0.0/0',
'read-access': 'yes',
'write-access': 'no',
security: 'none',
'authentication-protocol': 'MD5',
'encryption-protocol': 'DES',
})
setCommunityOpen(true)
}, [])
const handleEditCommunity = useCallback((entry: Record<string, string>) => {
setEditEntry(entry)
setCommunityForm({
name: entry['name'] || '',
addresses: entry['addresses'] || '',
'read-access': entry['read-access'] || 'yes',
'write-access': entry['write-access'] || 'no',
security: entry['security'] || 'none',
'authentication-protocol': entry['authentication-protocol'] || 'MD5',
'encryption-protocol': entry['encryption-protocol'] || 'DES',
})
setCommunityOpen(true)
}, [])
const handleSaveCommunity = useCallback(() => {
if (!communityForm['name']) return
if (editEntry) {
const props: Record<string, string> = {}
Object.entries(communityForm).forEach(([key, value]) => {
if (value !== editEntry[key]) props[key] = value
})
if (Object.keys(props).length === 0) { setCommunityOpen(false); return }
panel.addChange({
operation: 'set',
path: '/snmp/community',
entryId: editEntry['.id'],
properties: props,
description: `Update SNMP community "${communityForm['name']}"`,
})
} else {
panel.addChange({
operation: 'add',
path: '/snmp/community',
properties: communityForm,
description: `Add SNMP community "${communityForm['name']}"`,
})
}
setCommunityOpen(false)
}, [communityForm, editEntry, panel])
const handleDeleteCommunity = useCallback((entry: Record<string, string>) => {
panel.addChange({
operation: 'remove',
path: '/snmp/community',
entryId: entry['.id'],
properties: {},
description: `Remove SNMP community "${entry['name']}"`,
})
}, [panel])
if (snmp.isLoading) {
return <div className="flex items-center justify-center py-12 text-text-secondary text-sm">Loading SNMP settings...</div>
}
if (snmp.error) {
return <div className="flex items-center justify-center py-12 text-error text-sm">Failed to load. <button className="underline ml-1" onClick={() => snmp.refetch()}>Retry</button></div>
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* SNMP Status + Settings */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">SNMP Service</span>
</div>
<div className="flex gap-2">
<button
onClick={handleToggleSnmp}
className={cn(
'text-xs px-2.5 py-1 rounded border transition-colors',
isEnabled
? 'border-success/50 bg-success/10 text-success'
: 'border-border bg-elevated text-text-muted',
)}
>
{isEnabled ? 'Enabled' : 'Disabled'}
</button>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEditSettings}>
<Pencil className="h-3.5 w-3.5" /> Settings
</Button>
</div>
</div>
<div className="px-4 py-3 space-y-1.5">
<InfoRow label="Contact" value={snmpData['contact']} />
<InfoRow label="Location" value={snmpData['location']} />
<InfoRow label="Engine ID" value={snmpData['engine-id']} />
<InfoRow label="Trap Target" value={snmpData['trap-target']} />
<InfoRow label="Trap Community" value={snmpData['trap-community']} />
<InfoRow label="Trap Version" value={snmpData['trap-version']} />
</div>
</div>
{/* Communities */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">SNMP Communities</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAddCommunity}>
<Plus className="h-3.5 w-3.5" /> Add
</Button>
</div>
{communities.entries.length === 0 ? (
<div className="p-4 text-center text-sm text-text-muted">No SNMP communities configured.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 text-text-muted">
<th className="text-left px-3 py-2">Name</th>
<th className="text-left px-3 py-2">Addresses</th>
<th className="text-center px-3 py-2">Read</th>
<th className="text-center px-3 py-2">Write</th>
<th className="text-left px-3 py-2">Security</th>
<th className="text-right px-3 py-2">Actions</th>
</tr>
</thead>
<tbody className="font-mono">
{communities.entries.map((entry) => (
<tr key={entry['.id']} className="border-b border-border/20 last:border-0">
<td className="px-3 py-1.5 text-text-primary font-medium">{entry['name']}</td>
<td className="px-3 py-1.5 text-text-secondary">{entry['addresses'] || '0.0.0.0/0'}</td>
<td className="px-3 py-1.5 text-center">
<span className={entry['read-access'] === 'yes' ? 'text-success' : 'text-text-muted'}>
{entry['read-access'] === 'yes' ? 'Y' : 'N'}
</span>
</td>
<td className="px-3 py-1.5 text-center">
<span className={entry['write-access'] === 'yes' ? 'text-warning' : 'text-text-muted'}>
{entry['write-access'] === 'yes' ? 'Y' : 'N'}
</span>
</td>
<td className="px-3 py-1.5 text-text-secondary">{entry['security'] || 'none'}</td>
<td className="px-3 py-1.5 text-right">
<div className="flex gap-1 justify-end">
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => handleEditCommunity(entry)}>
<Pencil className="h-3 w-3" />
</Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-error" onClick={() => handleDeleteCommunity(entry)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* SNMP Settings dialog */}
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>SNMP Settings</DialogTitle>
<DialogDescription>Configure SNMP service settings. Changes are staged.</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-2 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Contact</Label>
<Input value={settingsForm['contact'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, contact: e.target.value }))} className="h-8 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Location</Label>
<Input value={settingsForm['location'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, location: e.target.value }))} className="h-8 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Engine ID</Label>
<Input value={settingsForm['engine-id'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, 'engine-id': e.target.value }))} className="h-8 text-sm font-mono" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Trap Target</Label>
<Input value={settingsForm['trap-target'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, 'trap-target': e.target.value }))} placeholder="192.168.1.100" className="h-8 text-sm font-mono" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Trap Community</Label>
<Input value={settingsForm['trap-community'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, 'trap-community': e.target.value }))} className="h-8 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Trap Version</Label>
<select value={settingsForm['trap-version'] || '1'} onChange={(e) => setSettingsForm((f) => ({ ...f, 'trap-version': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
<option value="1">v1</option>
<option value="2">v2c</option>
<option value="3">v3</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSettingsOpen(false)}>Cancel</Button>
<Button onClick={handleSaveSettings}>Stage Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Community dialog */}
<Dialog open={communityOpen} onOpenChange={setCommunityOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editEntry ? 'Edit Community' : 'Add Community'}</DialogTitle>
<DialogDescription>Configure SNMP community string. Changes are staged.</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Name</Label>
<Input value={communityForm['name'] || ''} onChange={(e) => setCommunityForm((f) => ({ ...f, name: e.target.value }))} placeholder="public" className="h-8 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Addresses</Label>
<Input value={communityForm['addresses'] || ''} onChange={(e) => setCommunityForm((f) => ({ ...f, addresses: e.target.value }))} placeholder="0.0.0.0/0" className="h-8 text-sm font-mono" />
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Read Access</Label>
<select value={communityForm['read-access'] || 'yes'} onChange={(e) => setCommunityForm((f) => ({ ...f, 'read-access': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Write Access</Label>
<select value={communityForm['write-access'] || 'no'} onChange={(e) => setCommunityForm((f) => ({ ...f, 'write-access': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Security</Label>
<select value={communityForm['security'] || 'none'} onChange={(e) => setCommunityForm((f) => ({ ...f, security: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
<option value="none">None</option>
<option value="authorized">Authorized</option>
<option value="private">Private</option>
</select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCommunityOpen(false)}>Cancel</Button>
<Button onClick={handleSaveCommunity} disabled={!communityForm['name']}>Stage Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ChangePreviewModal open={previewOpen} onOpenChange={setPreviewOpen} changes={panel.pendingChanges} applyMode={panel.applyMode}
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }} isApplying={panel.isApplying} />
</div>
)
}
function InfoRow({ label, value }: { label: string; value?: string }) {
return (
<div className="flex items-start gap-4 py-1 border-b border-border/20 last:border-0">
<span className="text-xs text-text-muted w-28 flex-shrink-0">{label}</span>
<span className="text-sm text-text-primary font-mono">{value || '—'}</span>
</div>
)
}

View File

@@ -0,0 +1,339 @@
/**
* SwitchPortManager -- Visual switch port layout with VLAN color coding.
*
* Displays physical ethernet ports in a horizontal grid resembling a
* physical switch front panel. Each port shows link status, speed, and
* VLAN assignment with color-coded border stripes. Clicking a port
* opens a detail popover (read-only).
*/
import { useMemo } from 'react'
import { Zap, Network } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useConfigBrowse } from '@/hooks/useConfigPanel'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
import { cn } from '@/lib/utils'
// ---------------------------------------------------------------------------
// VLAN color palette (uses chart CSS variable indices)
// ---------------------------------------------------------------------------
const VLAN_COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-2))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-5))',
'hsl(var(--chart-6, 280 65% 60%))', // fallback if chart-6 not defined
]
const UNASSIGNED_COLOR = 'hsl(var(--border))'
// ---------------------------------------------------------------------------
// Speed helpers
// ---------------------------------------------------------------------------
function parseSpeed(entry: Record<string, string>): string {
// Try the 'rate' or 'speed' property first (CRS switches expose this)
const speed = entry.speed || entry.rate || ''
if (speed) {
// RouterOS may return e.g. "1Gbps", "10Gbps", "100Mbps"
if (/10[gG]/.test(speed)) return '10G'
if (/1[gG]/.test(speed)) return '1G'
if (/100[mM]/.test(speed)) return '100M'
if (/10[mM]/.test(speed)) return '10M'
return speed
}
// Fallback: try to infer from actual-mtu or name
const mtu = Number(entry['actual-mtu'] || entry.mtu || 0)
if (mtu >= 9000) return '10G'
return '---'
}
// ---------------------------------------------------------------------------
// SwitchPortManager
// ---------------------------------------------------------------------------
export function SwitchPortManager({ tenantId, deviceId, active }: ConfigPanelProps) {
// Browse interfaces and bridge port assignments
const interfaces = useConfigBrowse(tenantId, deviceId, '/interface', { enabled: active })
const bridgePorts = useConfigBrowse(tenantId, deviceId, '/interface/bridge/port', {
enabled: active,
})
const vlans = useConfigBrowse(tenantId, deviceId, '/interface/vlan', { enabled: active })
// Filter to only ethernet interfaces
const etherPorts = useMemo(() => {
return interfaces.entries
.filter(
(e) =>
(e.type || '').toLowerCase().includes('ether') ||
(e.name || '').toLowerCase().startsWith('ether') ||
(e.name || '').toLowerCase().startsWith('sfp'),
)
.sort((a, b) => {
// Natural sort: ether1, ether2, ..., ether10, sfp1, etc.
const nameA = a.name || ''
const nameB = b.name || ''
const numA = parseInt(nameA.replace(/\D/g, ''), 10) || 0
const numB = parseInt(nameB.replace(/\D/g, ''), 10) || 0
if (nameA.startsWith('sfp') && !nameB.startsWith('sfp')) return 1
if (!nameA.startsWith('sfp') && nameB.startsWith('sfp')) return -1
return numA - numB
})
}, [interfaces.entries])
// Build VLAN color map: pvid -> color index
const { vlanColorMap, vlanLegend } = useMemo(() => {
const pvidSet = new Set<string>()
for (const bp of bridgePorts.entries) {
if (bp.pvid && bp.pvid !== '1') {
pvidSet.add(bp.pvid)
}
}
// Also include VLANs from the VLAN interface list
for (const v of vlans.entries) {
if (v['vlan-id']) {
pvidSet.add(v['vlan-id'])
}
}
const colorMap = new Map<string, string>()
const legend: { id: string; name: string; color: string }[] = []
let colorIdx = 0
for (const pid of Array.from(pvidSet).sort((a, b) => Number(a) - Number(b))) {
const color = VLAN_COLORS[colorIdx % VLAN_COLORS.length]
colorMap.set(pid, color)
// Find VLAN name if available
const vlanEntry = vlans.entries.find((v) => v['vlan-id'] === pid)
legend.push({
id: pid,
name: vlanEntry?.name || `VLAN ${pid}`,
color,
})
colorIdx++
}
return { vlanColorMap: colorMap, vlanLegend: legend }
}, [bridgePorts.entries, vlans.entries])
// Map interface name -> bridge port entry for quick lookup
const portAssignments = useMemo(() => {
const map = new Map<string, Record<string, string>>()
for (const bp of bridgePorts.entries) {
if (bp.interface) {
map.set(bp.interface, bp)
}
}
return map
}, [bridgePorts.entries])
const isLoading = interfaces.isLoading || bridgePorts.isLoading
if (isLoading) {
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="flex flex-wrap gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-16 rounded-md" />
))}
</div>
</div>
)
}
if (etherPorts.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
<Network className="h-8 w-8 mx-auto mb-2 opacity-40" />
No ethernet ports detected on this device.
</div>
)
}
return (
<div className="space-y-4">
{/* Port grid */}
<div className="rounded-lg border border-border bg-surface p-4">
<h3 className="text-sm font-medium text-text-primary mb-3">Switch Ports</h3>
<div className="flex flex-wrap gap-2">
{etherPorts.map((port) => {
const assignment = portAssignments.get(port.name)
const pvid = assignment?.pvid
const vlanColor = pvid && pvid !== '1' ? vlanColorMap.get(pvid) : undefined
return (
<PortCard
key={port['.id'] || port.name}
port={port}
assignment={assignment}
vlanColor={vlanColor || UNASSIGNED_COLOR}
speed={parseSpeed(port)}
/>
)
})}
</div>
</div>
{/* VLAN Legend */}
<div className="rounded-lg border border-border bg-surface p-4">
<h3 className="text-sm font-medium text-text-primary mb-2">VLAN Legend</h3>
<div className="flex flex-wrap gap-3">
<LegendItem color={UNASSIGNED_COLOR} label="Unassigned" />
{vlanLegend.map((item) => (
<LegendItem
key={item.id}
color={item.color}
label={`${item.name} (ID: ${item.id})`}
/>
))}
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// PortCard
// ---------------------------------------------------------------------------
function PortCard({
port,
assignment,
vlanColor,
speed,
}: {
port: Record<string, string>
assignment: Record<string, string> | undefined
vlanColor: string
speed: string
}) {
const isRunning = port.running === 'true'
const isDisabled = port.disabled === 'true'
const isUp = isRunning && !isDisabled
const portName = port.name || '---'
// PoE heuristic: ports that include "poe" in name or device has PoE capability
const hasPoe =
portName.toLowerCase().includes('poe') || portName.toLowerCase().startsWith('ether')
return (
<Popover>
<PopoverTrigger asChild>
<button
className={cn(
'relative flex flex-col items-center rounded-md border bg-elevated p-2 cursor-pointer hover:border-accent/50 transition-colors',
'w-[60px] h-[80px] justify-between',
!isUp && 'opacity-60',
)}
style={{ borderLeftWidth: '4px', borderLeftColor: vlanColor }}
>
{/* Port name */}
<span className="text-[10px] font-medium text-text-secondary truncate w-full text-center">
{portName}
</span>
{/* Link status indicator */}
<span
className={cn(
'inline-block h-3 w-3 rounded-full border-2',
isDisabled
? 'bg-text-muted border-text-muted/50'
: isRunning
? 'bg-success border-success/50'
: 'bg-text-muted border-text-muted/50',
)}
/>
{/* Speed badge */}
<span className="text-[9px] font-mono text-text-muted">{speed}</span>
{/* PoE indicator */}
{hasPoe && (
<Zap
className={cn(
'absolute top-1 right-1 h-2.5 w-2.5',
isUp ? 'text-warning' : 'text-text-muted/40',
)}
/>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-64" side="bottom">
<PortDetail port={port} assignment={assignment} speed={speed} />
</PopoverContent>
</Popover>
)
}
// ---------------------------------------------------------------------------
// Port Detail Popover Content
// ---------------------------------------------------------------------------
function PortDetail({
port,
assignment,
speed,
}: {
port: Record<string, string>
assignment: Record<string, string> | undefined
speed: string
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm text-text-primary">{port.name}</h4>
<Badge
className={cn(
port.running === 'true' && port.disabled !== 'true'
? 'bg-success/20 text-success border-success/40'
: 'bg-text-muted/20 text-text-muted border-text-muted/40',
)}
>
{port.disabled === 'true' ? 'Disabled' : port.running === 'true' ? 'Up' : 'Down'}
</Badge>
</div>
<div className="grid grid-cols-2 gap-y-1.5 text-xs">
<span className="text-text-secondary">MAC Address</span>
<span className="font-mono text-text-primary">{port['mac-address'] || '---'}</span>
<span className="text-text-secondary">Speed</span>
<span className="text-text-primary">{speed}</span>
<span className="text-text-secondary">MTU</span>
<span className="text-text-primary">{port.mtu || port['actual-mtu'] || '---'}</span>
<span className="text-text-secondary">Type</span>
<span className="text-text-primary">{port.type || '---'}</span>
{assignment && (
<>
<span className="text-text-secondary">Bridge</span>
<span className="text-text-primary">{assignment.bridge || '---'}</span>
<span className="text-text-secondary">PVID</span>
<span className="text-text-primary">{assignment.pvid || '1'}</span>
</>
)}
</div>
<p className="text-[10px] text-text-muted pt-1 border-t border-border">
Edit in Interfaces tab
</p>
</div>
)
}
// ---------------------------------------------------------------------------
// Legend Item
// ---------------------------------------------------------------------------
function LegendItem({ color, label }: { color: string; label: string }) {
return (
<div className="flex items-center gap-1.5 text-xs text-text-secondary">
<span className="inline-block h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
{label}
</div>
)
}

View File

@@ -0,0 +1,477 @@
/**
* SystemPanel -- System identity, clock/NTP, and resource display.
*
* Sub-tabs:
* 1. Identity -- view/edit system identity
* 2. Clock -- timezone and NTP client config
* 3. Resources -- read-only CPU, memory, disk, uptime, board, version
*/
import { useState, useCallback } from 'react'
import { Pencil, Clock, Cpu, Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Sub-tab types
// ---------------------------------------------------------------------------
type SubTab = 'identity' | 'clock' | 'resources'
const SUB_TABS: { key: SubTab; label: string; icon: React.ReactNode }[] = [
{ key: 'identity', label: 'Identity', icon: <Info className="h-3.5 w-3.5" /> },
{ key: 'clock', label: 'Clock & NTP', icon: <Clock className="h-3.5 w-3.5" /> },
{ key: 'resources', label: 'Resources', icon: <Cpu className="h-3.5 w-3.5" /> },
]
// ---------------------------------------------------------------------------
// SystemPanel
// ---------------------------------------------------------------------------
export function SystemPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const [activeTab, setActiveTab] = useState<SubTab>('identity')
const identity = useConfigBrowse(tenantId, deviceId, '/system/identity', { enabled: active })
const clock = useConfigBrowse(tenantId, deviceId, '/system/clock', { enabled: active })
const ntp = useConfigBrowse(tenantId, deviceId, '/system/ntp/client', { enabled: active })
const resource = useConfigBrowse(tenantId, deviceId, '/system/resource', { enabled: active })
const panel = useConfigPanel(tenantId, deviceId, 'system')
const [previewOpen, setPreviewOpen] = useState(false)
const isLoading = identity.isLoading || clock.isLoading || resource.isLoading
const hasError = identity.error || clock.error || resource.error
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading system configuration...
</div>
)
}
if (hasError) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load system configuration.{' '}
<button className="underline ml-1" onClick={() => { identity.refetch(); clock.refetch(); resource.refetch() }}>
Retry
</button>
</div>
)
}
// System identity is a single record (not a list)
const identityData = identity.entries[0] ?? {}
const clockData = clock.entries[0] ?? {}
const ntpData = ntp.entries[0] ?? {}
const resourceData = resource.entries[0] ?? {}
return (
<div className="space-y-4">
{/* Header with SafetyToggle and Apply button */}
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* Sub-tab navigation */}
<div className="flex gap-1 p-1 rounded-lg bg-elevated">
{SUB_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
activeTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'identity' && (
<IdentityTab data={identityData} panel={panel} />
)}
{activeTab === 'clock' && (
<ClockTab clockData={clockData} ntpData={ntpData} panel={panel} />
)}
{activeTab === 'resources' && (
<ResourcesTab data={resourceData} />
)}
{/* Change Preview Modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Panel type shorthand
// ---------------------------------------------------------------------------
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// Identity Tab
// ---------------------------------------------------------------------------
function IdentityTab({
data,
panel,
}: {
data: Record<string, string>
panel: PanelHook
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [name, setName] = useState('')
const handleEdit = useCallback(() => {
setName(data.name || '')
setDialogOpen(true)
}, [data.name])
const handleSave = useCallback(() => {
if (!name.trim()) return
panel.addChange({
operation: 'set',
path: '/system/identity',
properties: { name: name.trim() },
description: `Set system identity to "${name.trim()}"`,
})
setDialogOpen(false)
}, [name, panel])
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">System Identity</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
</div>
<div className="px-4 py-3">
<div className="flex items-center gap-4">
<span className="text-xs text-text-muted w-24">Identity</span>
<span className="text-sm text-text-primary font-medium">{data.name || '—'}</span>
</div>
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit System Identity</DialogTitle>
<DialogDescription>
Change the device's system name. This is staged until you apply.
</DialogDescription>
</DialogHeader>
<div className="space-y-1 mt-2">
<Label htmlFor="sys-name" className="text-xs text-text-secondary">
System Name
</Label>
<Input
id="sys-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="MikroTik"
className="h-8 text-sm"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!name.trim()}>Stage Change</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
// ---------------------------------------------------------------------------
// Clock Tab
// ---------------------------------------------------------------------------
function ClockTab({
clockData,
ntpData,
panel,
}: {
clockData: Record<string, string>
ntpData: Record<string, string>
panel: PanelHook
}) {
const [editClock, setEditClock] = useState(false)
const [timezone, setTimezone] = useState('')
const [editNtp, setEditNtp] = useState(false)
const [ntpServer, setNtpServer] = useState('')
const [ntpEnabled, setNtpEnabled] = useState('')
const handleEditClock = useCallback(() => {
setTimezone(clockData['time-zone-name'] || '')
setEditClock(true)
}, [clockData])
const handleSaveClock = useCallback(() => {
if (!timezone.trim()) return
panel.addChange({
operation: 'set',
path: '/system/clock',
properties: { 'time-zone-name': timezone.trim() },
description: `Set timezone to "${timezone.trim()}"`,
})
setEditClock(false)
}, [timezone, panel])
const handleEditNtp = useCallback(() => {
setNtpServer(ntpData['primary-ntp'] || ntpData.server || '')
setNtpEnabled(ntpData.enabled || 'yes')
setEditNtp(true)
}, [ntpData])
const handleSaveNtp = useCallback(() => {
const props: Record<string, string> = { enabled: ntpEnabled }
if (ntpServer.trim()) {
props['primary-ntp'] = ntpServer.trim()
}
panel.addChange({
operation: 'set',
path: '/system/ntp/client',
properties: props,
description: `Configure NTP client (${ntpEnabled === 'yes' ? 'enabled' : 'disabled'}${ntpServer ? ', server: ' + ntpServer : ''})`,
})
setEditNtp(false)
}, [ntpServer, ntpEnabled, panel])
return (
<div className="space-y-4">
{/* Clock info */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">Clock Settings</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEditClock}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
</div>
<div className="px-4 py-3 space-y-2">
<InfoRow label="Date" value={clockData.date} />
<InfoRow label="Time" value={clockData.time} />
<InfoRow label="Timezone" value={clockData['time-zone-name']} />
<InfoRow label="GMT Offset" value={clockData['gmt-offset']} />
</div>
</div>
{/* NTP info */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">NTP Client</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEditNtp}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
</div>
<div className="px-4 py-3 space-y-2">
<InfoRow label="Enabled" value={ntpData.enabled} />
<InfoRow label="Server" value={ntpData['primary-ntp'] || ntpData.server} />
<InfoRow label="Status" value={ntpData.status} />
</div>
</div>
{/* Clock edit dialog */}
<Dialog open={editClock} onOpenChange={setEditClock}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Clock Settings</DialogTitle>
<DialogDescription>Change the timezone. Staged until you apply.</DialogDescription>
</DialogHeader>
<div className="space-y-1 mt-2">
<Label className="text-xs text-text-secondary">Timezone</Label>
<Input
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
placeholder="America/New_York"
className="h-8 text-sm"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditClock(false)}>Cancel</Button>
<Button onClick={handleSaveClock} disabled={!timezone.trim()}>Stage Change</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* NTP edit dialog */}
<Dialog open={editNtp} onOpenChange={setEditNtp}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit NTP Client</DialogTitle>
<DialogDescription>Configure NTP server. Staged until you apply.</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Enabled</Label>
<select
value={ntpEnabled}
onChange={(e) => setNtpEnabled(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Primary NTP Server</Label>
<Input
value={ntpServer}
onChange={(e) => setNtpServer(e.target.value)}
placeholder="pool.ntp.org"
className="h-8 text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditNtp(false)}>Cancel</Button>
<Button onClick={handleSaveNtp}>Stage Change</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ---------------------------------------------------------------------------
// Resources Tab (read-only)
// ---------------------------------------------------------------------------
function ResourcesTab({ data }: { data: Record<string, string> }) {
const memTotal = parseInt(data['total-memory'] || '0', 10)
const memFree = parseInt(data['free-memory'] || '0', 10)
const memUsed = memTotal - memFree
const memPct = memTotal > 0 ? Math.round((memUsed / memTotal) * 100) : 0
const diskTotal = parseInt(data['total-hdd-space'] || '0', 10)
const diskFree = parseInt(data['free-hdd-space'] || '0', 10)
const diskUsed = diskTotal - diskFree
const diskPct = diskTotal > 0 ? Math.round((diskUsed / diskTotal) * 100) : 0
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">System Resources</span>
</div>
<div className="px-4 py-3 space-y-2">
<InfoRow label="Board" value={data['board-name']} />
<InfoRow label="Architecture" value={data.architecture} />
<InfoRow label="CPU" value={data.cpu} />
<InfoRow label="CPU Count" value={data['cpu-count']} />
<InfoRow label="CPU Load" value={data['cpu-load'] ? `${data['cpu-load']}%` : undefined} />
<InfoRow label="Version" value={data.version} />
<InfoRow label="Uptime" value={data.uptime} />
{/* Memory bar */}
<div className="flex items-start gap-4 py-2 border-b border-border/50">
<span className="text-xs text-text-muted w-24 flex-shrink-0 pt-0.5">Memory</span>
<div className="flex-1">
<div className="flex items-center gap-2 text-sm text-text-primary">
<div className="flex-1 h-2 rounded-full bg-elevated overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
memPct > 90 ? 'bg-error' : memPct > 70 ? 'bg-warning' : 'bg-accent',
)}
style={{ width: `${memPct}%` }}
/>
</div>
<span className="text-xs text-text-secondary w-14 text-right">{memPct}%</span>
</div>
<span className="text-xs text-text-muted">
{formatBytes(memUsed)} / {formatBytes(memTotal)}
</span>
</div>
</div>
{/* Disk bar */}
<div className="flex items-start gap-4 py-2">
<span className="text-xs text-text-muted w-24 flex-shrink-0 pt-0.5">Disk</span>
<div className="flex-1">
<div className="flex items-center gap-2 text-sm text-text-primary">
<div className="flex-1 h-2 rounded-full bg-elevated overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
diskPct > 90 ? 'bg-error' : diskPct > 70 ? 'bg-warning' : 'bg-accent',
)}
style={{ width: `${diskPct}%` }}
/>
</div>
<span className="text-xs text-text-secondary w-14 text-right">{diskPct}%</span>
</div>
<span className="text-xs text-text-muted">
{formatBytes(diskUsed)} / {formatBytes(diskTotal)}
</span>
</div>
</div>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function InfoRow({ label, value }: { label: string; value?: string }) {
return (
<div className="flex items-start gap-4 py-1.5 border-b border-border/30 last:border-0">
<span className="text-xs text-text-muted w-24 flex-shrink-0">{label}</span>
<span className="text-sm text-text-primary">{value || ''}</span>
</div>
)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i] || 'TB'}`
}

View File

@@ -0,0 +1,247 @@
/**
* TorchTool -- Live traffic monitoring per interface.
*
* Uses /tool/torch via config editor execute.
* Filter by src/dst address, protocol, port.
* Auto-refresh with configurable interval.
*/
import { useState, useCallback, useEffect, useRef } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Play, Square, Flame, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { configEditorApi } from '@/lib/configEditorApi'
import { useConfigBrowse } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
interface TorchEntry {
srcAddress: string
dstAddress: string
protocol: string
srcPort: string
dstPort: string
txRate: string
rxRate: string
tx: string
rx: string
}
function formatBytes(val: string): string {
const n = parseInt(val, 10)
if (isNaN(n)) return val || '-'
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} GB`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} KB`
return `${n} B`
}
function formatBps(val: string): string {
const n = parseInt(val, 10)
if (isNaN(n)) return val || '-'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} Mbps`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} Kbps`
return `${n} bps`
}
export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
const interfaces = useConfigBrowse(tenantId, deviceId, '/interface', { enabled: active })
const [iface, setIface] = useState('ether1')
const [srcFilter, setSrcFilter] = useState('')
const [dstFilter, setDstFilter] = useState('')
const [protocolFilter, setProtocolFilter] = useState('')
const [portFilter, setPortFilter] = useState('')
const [autoRefresh, setAutoRefresh] = useState(false)
const [entries, setEntries] = useState<TorchEntry[]>([])
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const torchMutation = useMutation({
mutationFn: async () => {
const parts = ['/tool/torch', `interface=${iface}`, 'duration=3s']
if (srcFilter) parts.push(`src-address=${srcFilter}`)
if (dstFilter) parts.push(`dst-address=${dstFilter}`)
if (protocolFilter) parts.push(`protocol=${protocolFilter}`)
if (portFilter) parts.push(`port=${portFilter}`)
return configEditorApi.execute(tenantId, deviceId, parts.join(' '))
},
onSuccess: (resp) => {
if (!resp.success) { setEntries([]); return }
const rows: TorchEntry[] = resp.data
.filter((d) => d['src-address'] || d['dst-address'])
.map((d) => ({
srcAddress: d['src-address'] || '',
dstAddress: d['dst-address'] || '',
protocol: d['ip-protocol'] || d['protocol'] || '',
srcPort: d['src-port'] || '',
dstPort: d['dst-port'] || '',
txRate: d['tx-rate'] || d['tx'] || '',
rxRate: d['rx-rate'] || d['rx'] || '',
tx: d['tx-packets'] || '',
rx: d['rx-packets'] || '',
}))
setEntries(rows)
},
})
const handleRun = useCallback(() => {
torchMutation.mutate()
}, [torchMutation])
// Auto-refresh
useEffect(() => {
if (autoRefresh && !torchMutation.isPending) {
timerRef.current = setInterval(() => {
torchMutation.mutate()
}, 5000)
}
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [autoRefresh, torchMutation.isPending])
const handleToggleAuto = useCallback(() => {
if (autoRefresh) {
setAutoRefresh(false)
if (timerRef.current) clearInterval(timerRef.current)
} else {
setAutoRefresh(true)
handleRun()
}
}, [autoRefresh, handleRun])
const ifaceNames = interfaces.entries.map((e) => e['name']).filter(Boolean)
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Interface</Label>
<select
value={iface}
onChange={(e) => setIface(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
>
{ifaceNames.length > 0
? ifaceNames.map((name) => (
<option key={name} value={name}>{name}</option>
))
: <option value={iface}>{iface}</option>
}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Src Address</Label>
<Input
value={srcFilter}
onChange={(e) => setSrcFilter(e.target.value)}
placeholder="any"
className="h-8 text-sm font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Dst Address</Label>
<Input
value={dstFilter}
onChange={(e) => setDstFilter(e.target.value)}
placeholder="any"
className="h-8 text-sm font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Protocol</Label>
<Input
value={protocolFilter}
onChange={(e) => setProtocolFilter(e.target.value)}
placeholder="any"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Port</Label>
<Input
value={portFilter}
onChange={(e) => setPortFilter(e.target.value)}
placeholder="any"
className="h-8 text-sm"
/>
</div>
</div>
<div className="mt-3 flex gap-2">
<Button
onClick={handleRun}
disabled={torchMutation.isPending}
className="gap-1.5"
>
{torchMutation.isPending ? (
<><Square className="h-3.5 w-3.5" /> Capturing...</>
) : (
<><Play className="h-3.5 w-3.5" /> Capture</>
)}
</Button>
<Button
variant={autoRefresh ? 'destructive' : 'outline'}
onClick={handleToggleAuto}
className="gap-1.5"
>
<RefreshCw className={cn('h-3.5 w-3.5', autoRefresh && 'animate-spin')} />
{autoRefresh ? 'Stop Auto' : 'Auto Refresh'}
</Button>
</div>
</div>
{torchMutation.isError && (
<div className="rounded-lg border border-error/50 bg-error/10 p-4 text-sm text-error">
Failed to execute torch command.
</div>
)}
{entries.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Flame className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">
Torch {iface} ({entries.length} flows)
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 text-text-muted">
<th className="text-left px-3 py-2">Src Address</th>
<th className="text-left px-3 py-2">Dst Address</th>
<th className="text-left px-3 py-2">Proto</th>
<th className="text-left px-3 py-2">Src Port</th>
<th className="text-left px-3 py-2">Dst Port</th>
<th className="text-right px-3 py-2">TX Rate</th>
<th className="text-right px-3 py-2">RX Rate</th>
</tr>
</thead>
<tbody className="font-mono">
{entries.map((e, i) => (
<tr key={i} className="border-b border-border/20 last:border-0">
<td className="px-3 py-1.5 text-text-primary">{e.srcAddress || '-'}</td>
<td className="px-3 py-1.5 text-text-primary">{e.dstAddress || '-'}</td>
<td className="px-3 py-1.5 text-text-secondary">{e.protocol || '-'}</td>
<td className="px-3 py-1.5 text-text-muted">{e.srcPort || '-'}</td>
<td className="px-3 py-1.5 text-text-muted">{e.dstPort || '-'}</td>
<td className="px-3 py-1.5 text-right text-accent">{formatBps(e.txRate)}</td>
<td className="px-3 py-1.5 text-right text-info">{formatBps(e.rxRate)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{entries.length === 0 && !torchMutation.isPending && !torchMutation.isIdle && (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
No traffic captured. Try a different interface or remove filters.
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,189 @@
/**
* TracerouteTool -- Traceroute from device to target.
*
* Uses /tool/traceroute command via config editor execute.
* Displays hop-by-hop results with IP, hostname, RTT.
* Configurable timeout, protocol, max-hops.
*/
import { useState, useCallback } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Play, Square, Route } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { configEditorApi } from '@/lib/configEditorApi'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
interface HopResult {
hop: string
address: string
hostname: string
rtt1: string
rtt2: string
rtt3: string
loss: string
status: string
}
export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
const [target, setTarget] = useState('')
const [maxHops, setMaxHops] = useState('30')
const [timeout, setTimeout] = useState('1000')
const [protocol, setProtocol] = useState('icmp')
const [hops, setHops] = useState<HopResult[]>([])
const traceMutation = useMutation({
mutationFn: async () => {
const parts = ['/tool/traceroute', `address=${target}`, `count=3`]
if (maxHops !== '30') parts.push(`max-hops=${maxHops}`)
if (timeout !== '1000') parts.push(`timeout=${timeout}ms`)
if (protocol !== 'icmp') parts.push(`protocol=${protocol}`)
return configEditorApi.execute(tenantId, deviceId, parts.join(' '))
},
onSuccess: (resp) => {
if (!resp.success) {
setHops([])
return
}
const rows: HopResult[] = resp.data.map((d) => ({
hop: d['#'] || d['hop'] || '',
address: d['address'] || d['host'] || '',
hostname: d['hostname'] || '',
rtt1: d['avg-rtt'] || d['last'] || d['time1'] || '',
rtt2: d['time2'] || '',
rtt3: d['time3'] || '',
loss: d['loss'] || d['packet-loss'] || '',
status: d['status'] || (d['address'] ? 'ok' : 'timeout'),
}))
setHops(rows)
},
})
const handleRun = useCallback(() => {
if (!target.trim()) return
setHops([])
traceMutation.mutate()
}, [target, traceMutation])
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="space-y-1 col-span-2">
<Label className="text-xs text-text-secondary">Target IP / Hostname</Label>
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="8.8.8.8"
className="h-8 text-sm font-mono"
onKeyDown={(e) => e.key === 'Enter' && handleRun()}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Max Hops</Label>
<Input
type="number"
value={maxHops}
onChange={(e) => setMaxHops(e.target.value)}
min={1}
max={64}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Timeout (ms)</Label>
<Input
type="number"
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
min={100}
max={10000}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Protocol</Label>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
>
<option value="icmp">ICMP</option>
<option value="udp">UDP</option>
</select>
</div>
</div>
<div className="mt-3">
<Button
onClick={handleRun}
disabled={!target.trim() || traceMutation.isPending}
className="gap-1.5"
>
{traceMutation.isPending ? (
<><Square className="h-3.5 w-3.5" /> Running...</>
) : (
<><Play className="h-3.5 w-3.5" /> Traceroute</>
)}
</Button>
</div>
</div>
{traceMutation.isError && (
<div className="rounded-lg border border-error/50 bg-error/10 p-4 text-sm text-error">
Failed to execute traceroute command.
</div>
)}
{hops.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Route className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">
Traceroute to {target} ({hops.length} hops)
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 text-text-muted">
<th className="text-left px-4 py-2 w-12">#</th>
<th className="text-left px-4 py-2">Address</th>
<th className="text-left px-4 py-2">Hostname</th>
<th className="text-right px-4 py-2">RTT</th>
<th className="text-right px-4 py-2">Loss</th>
</tr>
</thead>
<tbody className="font-mono">
{hops.map((hop, i) => (
<tr
key={i}
className={cn(
'border-b border-border/20 last:border-0',
hop.status === 'timeout' ? 'text-text-muted' : 'text-text-primary',
)}
>
<td className="px-4 py-1.5 text-text-muted">{hop.hop || i + 1}</td>
<td className="px-4 py-1.5">{hop.address || '* * *'}</td>
<td className="px-4 py-1.5 text-text-secondary">{hop.hostname || '-'}</td>
<td className="px-4 py-1.5 text-right">
{hop.rtt1 ? `${hop.rtt1}ms` : '*'}
</td>
<td className="px-4 py-1.5 text-right">
{hop.loss && hop.loss !== '0' ? (
<span className="text-warning">{hop.loss}%</span>
) : (
<span className="text-success">0%</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,416 @@
/**
* UsersPanel -- RouterOS user management panel.
*
* View/add/edit/delete RouterOS users (/user), group assignment
* with permission display (/user/group), password change.
* Safe apply mode by default.
*/
import { useState, useCallback, useMemo } from 'react'
import { Plus, Pencil, Trash2, Users, Shield } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { SafetyToggle } from './SafetyToggle'
import { ChangePreviewModal } from './ChangePreviewModal'
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
import { cn } from '@/lib/utils'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Entry & form types
// ---------------------------------------------------------------------------
interface UserEntry {
'.id': string
name: string
group: string
'allowed-address': string
'last-logged-in': string
disabled: string
[key: string]: string
}
interface GroupEntry {
'.id': string
name: string
policy: string
[key: string]: string
}
interface UserForm {
name: string
group: string
password: string
'allowed-address': string
}
const EMPTY_FORM: UserForm = {
name: '',
group: 'read',
password: '',
'allowed-address': '',
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
function validateUserForm(form: UserForm, isEditing: boolean): Record<string, string> {
const errors: Record<string, string> = {}
if (!form.name) {
errors.name = 'Username is required'
}
if (!form.group) {
errors.group = 'Group is required'
}
if (!isEditing && !form.password) {
errors.password = 'Password is required for new users'
}
return errors
}
// ---------------------------------------------------------------------------
// Panel type shorthand
// ---------------------------------------------------------------------------
type PanelHook = ReturnType<typeof useConfigPanel>
// ---------------------------------------------------------------------------
// UsersPanel
// ---------------------------------------------------------------------------
export function UsersPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
const { entries, isLoading, error, refetch } = useConfigBrowse(
tenantId,
deviceId,
'/user',
{ enabled: active },
)
const { entries: groupEntries } = useConfigBrowse(
tenantId,
deviceId,
'/user/group',
{ enabled: active },
)
const panel = useConfigPanel(tenantId, deviceId, 'users')
const [previewOpen, setPreviewOpen] = useState(false)
const typedEntries = entries as UserEntry[]
const groups = groupEntries as GroupEntry[]
const groupNames = useMemo(() => groups.map((g) => g.name).filter(Boolean), [groups])
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary text-sm">
Loading RouterOS users...
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12 text-error text-sm">
Failed to load users.{' '}
<button className="underline ml-1" onClick={() => refetch()}>Retry</button>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<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})
</Button>
</div>
{/* Groups overview */}
{groups.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Shield className="h-4 w-4 text-text-muted" />
<span className="text-sm font-medium text-text-secondary">User Groups</span>
</div>
<div className="px-4 py-3 space-y-1.5">
{groups.map((g) => (
<div key={g['.id']} className="flex items-start gap-3">
<span className="text-sm text-text-primary font-medium w-20">{g.name}</span>
<span className="text-xs text-text-muted font-mono break-all">{g.policy || '—'}</span>
</div>
))}
</div>
</div>
)}
<UsersTable entries={typedEntries} panel={panel} groupNames={groupNames} />
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={panel.pendingChanges}
applyMode={panel.applyMode}
onConfirm={() => {
panel.applyChanges()
setPreviewOpen(false)
}}
isApplying={panel.isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Users Table
// ---------------------------------------------------------------------------
function UsersTable({
entries,
panel,
groupNames,
}: {
entries: UserEntry[]
panel: PanelHook
groupNames: string[]
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<UserEntry | null>(null)
const [form, setForm] = useState<UserForm>(EMPTY_FORM)
const [errors, setErrors] = useState<Record<string, string>>({})
const handleAdd = useCallback(() => {
setEditing(null)
setForm(EMPTY_FORM)
setErrors({})
setDialogOpen(true)
}, [])
const handleEdit = useCallback((entry: UserEntry) => {
setEditing(entry)
setForm({
name: entry.name || '',
group: entry.group || 'read',
password: '',
'allowed-address': entry['allowed-address'] || '',
})
setErrors({})
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(entry: UserEntry) => {
panel.addChange({
operation: 'remove',
path: '/user',
entryId: entry['.id'],
properties: {},
description: `Remove user "${entry.name}"`,
})
},
[panel],
)
const handleSave = useCallback(() => {
const validationErrors = validateUserForm(form, !!editing)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
const props: Record<string, string> = {
name: form.name,
group: form.group,
}
if (form.password) props.password = form.password
if (form['allowed-address']) props['allowed-address'] = form['allowed-address']
if (editing) {
panel.addChange({
operation: 'set',
path: '/user',
entryId: editing['.id'],
properties: props,
description: `Edit user "${form.name}" (group: ${form.group})`,
})
} else {
panel.addChange({
operation: 'add',
path: '/user',
properties: props,
description: `Add user "${form.name}" (group: ${form.group})`,
})
}
setDialogOpen(false)
}, [form, editing, panel])
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Users className="h-4 w-4" />
RouterOS Users ({entries.length})
</div>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5" />
Add User
</Button>
</div>
{entries.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">No users found.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-text-secondary text-xs">
<th className="text-left px-4 py-2 font-medium">Name</th>
<th className="text-left px-4 py-2 font-medium">Group</th>
<th className="text-left px-4 py-2 font-medium">Allowed Address</th>
<th className="text-left px-4 py-2 font-medium">Last Login</th>
<th className="text-left px-4 py-2 font-medium">Status</th>
<th className="text-right px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry['.id']}
className="border-b border-border/30 last:border-0 hover:bg-elevated/50 transition-colors"
>
<td className="px-4 py-2 text-text-primary font-medium">{entry.name || '—'}</td>
<td className="px-4 py-2">
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-info/10 text-info border-info/40">
{entry.group || '—'}
</span>
</td>
<td className="px-4 py-2 font-mono text-text-secondary text-xs">
{entry['allowed-address'] || 'any'}
</td>
<td className="px-4 py-2 text-text-muted text-xs">
{entry['last-logged-in'] || '—'}
</td>
<td className="px-4 py-2">
{entry.disabled === 'true' ? (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-error/10 text-error border-error/40">
disabled
</span>
) : (
<span className="text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border bg-success/10 text-success border-success/40">
active
</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => handleEdit(entry)} title="Edit user">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-error hover:text-error" onClick={() => handleDelete(entry)} title="Delete user">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? 'Edit User' : 'Add User'}</DialogTitle>
<DialogDescription>
{editing ? 'Modify the user. Leave password blank to keep unchanged.' : 'Create a new RouterOS user. Changes are staged until you apply.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Username</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="admin"
className={cn('h-8 text-sm', errors.name && 'border-error')}
/>
{errors.name && <p className="text-xs text-error">{errors.name}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Group</Label>
{groupNames.length > 0 ? (
<Select value={form.group} onValueChange={(v) => setForm((f) => ({ ...f, group: v }))}>
<SelectTrigger className={cn('h-8 text-sm', errors.group && 'border-error')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{groupNames.map((g) => (
<SelectItem key={g} value={g}>{g}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={form.group}
onChange={(e) => setForm((f) => ({ ...f, group: e.target.value }))}
placeholder="full"
className={cn('h-8 text-sm', errors.group && 'border-error')}
/>
)}
{errors.group && <p className="text-xs text-error">{errors.group}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">
Password {editing && '(leave blank to keep)'}
</Label>
<Input
type="password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
placeholder={editing ? 'unchanged' : 'required'}
className={cn('h-8 text-sm', errors.password && 'border-error')}
/>
{errors.password && <p className="text-xs text-error">{errors.password}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Allowed Address</Label>
<Input
value={form['allowed-address']}
onChange={(e) => setForm((f) => ({ ...f, 'allowed-address': e.target.value }))}
placeholder="0.0.0.0/0 (any)"
className="h-8 text-sm font-mono"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>{editing ? 'Stage Edit' : 'Stage User'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,987 @@
/**
* WifiPanel -- WiFi/SSID management with RouterOS version-aware path selection.
*
* RouterOS 6: /interface/wireless + /interface/wireless/security-profiles
* RouterOS 7+: /interface/wifi (security embedded in config)
*
* Provides two sub-tabs:
* 1. Wireless Interfaces -- SSID, band, channel, security
* 2. Security Profiles (RouterOS 6 only) -- authentication, passphrases
*/
import { useState, useMemo, useCallback } from 'react'
import {
Wifi,
Plus,
Pencil,
Trash2,
Power,
PowerOff,
Eye,
EyeOff,
Shield,
Radio,
Loader2,
AlertCircle,
} 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { useConfigBrowse } from '@/hooks/useConfigPanel'
import { useConfigPanel } from '@/hooks/useConfigPanel'
import { SafetyToggle } from '@/components/config/SafetyToggle'
import { ChangePreviewModal } from '@/components/config/ChangePreviewModal'
import type { ConfigPanelProps, ConfigChange } from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface WifiPanelProps extends ConfigPanelProps {
routerosVersion?: string | null
}
interface WirelessFormData {
ssid: string
band: string
'channel-width': string
frequency: string
'security-profile': string
disabled: string
// RouterOS 7 fields
'security.passphrase': string
}
interface SecurityProfileFormData {
name: string
mode: string
'authentication-types': string
'wpa-pre-shared-key': string
'wpa2-pre-shared-key': string
}
type SubTab = 'interfaces' | 'security-profiles'
// ---------------------------------------------------------------------------
// Version Detection
// ---------------------------------------------------------------------------
function parseMajorVersion(version: string | null | undefined): number {
if (!version) return 6
const match = version.match(/^(\d+)/)
return match ? parseInt(match[1], 10) : 6
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BAND_OPTIONS = [
{ value: '2ghz-b', label: '2.4 GHz B' },
{ value: '2ghz-b/g', label: '2.4 GHz B/G' },
{ value: '2ghz-b/g/n', label: '2.4 GHz B/G/N' },
{ value: '2ghz-g/n', label: '2.4 GHz G/N' },
{ value: '2ghz-onlyn', label: '2.4 GHz N Only' },
{ value: '5ghz-a', label: '5 GHz A' },
{ value: '5ghz-a/n', label: '5 GHz A/N' },
{ value: '5ghz-a/n/ac', label: '5 GHz A/N/AC' },
{ value: '5ghz-onlyac', label: '5 GHz AC Only' },
{ value: '5ghz-n/ac', label: '5 GHz N/AC' },
]
const CHANNEL_WIDTH_OPTIONS = ['20mhz', '20/40mhz-XX', '20/40mhz-Ce', '20/40mhz-eC', '40mhz-turbo', '20/40/80mhz-XXXX', '80mhz']
const SECURITY_MODE_OPTIONS = [
{ value: 'none', label: 'None' },
{ value: 'static-keys-required', label: 'Static Keys Required' },
{ value: 'static-keys-optional', label: 'Static Keys Optional' },
{ value: 'dynamic-keys', label: 'Dynamic Keys' },
]
const AUTH_TYPE_OPTIONS = ['wpa-psk', 'wpa2-psk', 'wpa-eap', 'wpa2-eap']
// ---------------------------------------------------------------------------
// WifiPanel Component
// ---------------------------------------------------------------------------
export function WifiPanel({ tenantId, deviceId, active, routerosVersion }: WifiPanelProps) {
const majorVersion = useMemo(() => parseMajorVersion(routerosVersion), [routerosVersion])
const isV7 = majorVersion >= 7
const wirelessPath = isV7 ? '/interface/wifi' : '/interface/wireless'
const [subTab, setSubTab] = useState<SubTab>('interfaces')
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState<Record<string, string> | null>(null)
const [secProfileDialogOpen, setSecProfileDialogOpen] = useState(false)
const [editingProfile, setEditingProfile] = useState<Record<string, string> | null>(null)
const [previewOpen, setPreviewOpen] = useState(false)
// Data loading
const { entries: interfaces, isLoading: loadingInterfaces, error: ifError } = useConfigBrowse(
tenantId, deviceId, wirelessPath, { enabled: active },
)
const { entries: securityProfiles, isLoading: loadingSecurity } = useConfigBrowse(
tenantId, deviceId, '/interface/wireless/security-profiles',
{ enabled: active && !isV7 },
)
// Config panel state
const {
pendingChanges, applyMode, setApplyMode,
addChange, clearChanges, applyChanges, isApplying,
} = useConfigPanel(tenantId, deviceId, 'wifi')
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handleEditInterface = useCallback((entry: Record<string, string>) => {
setEditingEntry(entry)
setEditDialogOpen(true)
}, [])
const handleToggleDisabled = useCallback((entry: Record<string, string>) => {
const isDisabled = entry.disabled === 'true' || entry.disabled === 'yes'
const change: ConfigChange = {
operation: 'set',
path: wirelessPath,
entryId: entry['.id'],
properties: { disabled: isDisabled ? 'no' : 'yes' },
description: `${isDisabled ? 'Enable' : 'Disable'} wireless interface "${entry.name}"`,
}
addChange(change)
}, [wirelessPath, addChange])
const handleDeleteInterface = useCallback((entry: Record<string, string>) => {
const change: ConfigChange = {
operation: 'remove',
path: wirelessPath,
entryId: entry['.id'],
properties: {},
description: `Remove wireless interface "${entry.name}"`,
}
addChange(change)
}, [wirelessPath, addChange])
const handleSaveInterface = useCallback((formData: WirelessFormData) => {
const properties: Record<string, string> = {}
if (formData.ssid) properties.ssid = formData.ssid
if (formData.band) properties.band = formData.band
if (formData['channel-width']) properties['channel-width'] = formData['channel-width']
if (formData.frequency) properties.frequency = formData.frequency
if (!isV7 && formData['security-profile']) {
properties['security-profile'] = formData['security-profile']
}
if (isV7 && formData['security.passphrase']) {
properties['security.passphrase'] = formData['security.passphrase']
}
properties.disabled = formData.disabled
if (editingEntry) {
const change: ConfigChange = {
operation: 'set',
path: wirelessPath,
entryId: editingEntry['.id'],
properties,
description: `Update wireless interface "${editingEntry.name}" (SSID: ${formData.ssid || editingEntry.ssid || 'unchanged'})`,
}
addChange(change)
}
setEditDialogOpen(false)
setEditingEntry(null)
}, [editingEntry, wirelessPath, isV7, addChange])
const handleEditProfile = useCallback((profile: Record<string, string>) => {
setEditingProfile(profile)
setSecProfileDialogOpen(true)
}, [])
const handleAddProfile = useCallback(() => {
setEditingProfile(null)
setSecProfileDialogOpen(true)
}, [])
const handleDeleteProfile = useCallback((profile: Record<string, string>) => {
const change: ConfigChange = {
operation: 'remove',
path: '/interface/wireless/security-profiles',
entryId: profile['.id'],
properties: {},
description: `Remove security profile "${profile.name}"`,
}
addChange(change)
}, [addChange])
const handleSaveProfile = useCallback((formData: SecurityProfileFormData) => {
const properties: Record<string, string> = {
name: formData.name,
mode: formData.mode,
'authentication-types': formData['authentication-types'],
}
if (formData['wpa-pre-shared-key']) {
properties['wpa-pre-shared-key'] = formData['wpa-pre-shared-key']
}
if (formData['wpa2-pre-shared-key']) {
properties['wpa2-pre-shared-key'] = formData['wpa2-pre-shared-key']
}
if (editingProfile) {
addChange({
operation: 'set',
path: '/interface/wireless/security-profiles',
entryId: editingProfile['.id'],
properties,
description: `Update security profile "${formData.name}"`,
})
} else {
addChange({
operation: 'add',
path: '/interface/wireless/security-profiles',
properties,
description: `Add security profile "${formData.name}"`,
})
}
setSecProfileDialogOpen(false)
setEditingProfile(null)
}, [editingProfile, addChange])
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
if (ifError) {
return (
<div className="flex items-center gap-2 p-6 text-error">
<AlertCircle className="h-4 w-4" />
<span>Failed to load wireless interfaces: {ifError.message}</span>
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-accent" />
<h3 className="text-sm font-semibold text-text-primary">WiFi Management</h3>
<Badge className="text-[10px]">
{isV7 ? 'RouterOS 7+' : 'RouterOS 6'}
</Badge>
</div>
<div className="flex items-center gap-3">
<SafetyToggle mode={applyMode} onModeChange={setApplyMode} />
{pendingChanges.length > 0 && (
<div className="flex items-center gap-2">
<Badge className="bg-accent/20 text-accent border-accent/40">
{pendingChanges.length} pending
</Badge>
<Button variant="ghost" size="sm" onClick={clearChanges}>
Clear
</Button>
<Button size="sm" onClick={() => setPreviewOpen(true)}>
Review & Apply
</Button>
</div>
)}
</div>
</div>
{/* Sub-tabs */}
<div className="flex gap-1 border-b border-border">
<button
onClick={() => setSubTab('interfaces')}
className={cn(
'px-3 py-1.5 text-sm font-medium border-b-2 transition-colors',
subTab === 'interfaces'
? 'border-accent text-accent'
: 'border-transparent text-text-secondary hover:text-text-primary',
)}
>
<span className="flex items-center gap-1.5">
<Radio className="h-3.5 w-3.5" />
Wireless Interfaces
</span>
</button>
{!isV7 && (
<button
onClick={() => setSubTab('security-profiles')}
className={cn(
'px-3 py-1.5 text-sm font-medium border-b-2 transition-colors',
subTab === 'security-profiles'
? 'border-accent text-accent'
: 'border-transparent text-text-secondary hover:text-text-primary',
)}
>
<span className="flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5" />
Security Profiles
</span>
</button>
)}
</div>
{/* Tab Content */}
{subTab === 'interfaces' && (
<WirelessInterfacesTable
entries={interfaces}
isLoading={loadingInterfaces}
isV7={isV7}
onEdit={handleEditInterface}
onToggle={handleToggleDisabled}
onDelete={handleDeleteInterface}
/>
)}
{subTab === 'security-profiles' && !isV7 && (
<SecurityProfilesTable
entries={securityProfiles}
isLoading={loadingSecurity}
onEdit={handleEditProfile}
onAdd={handleAddProfile}
onDelete={handleDeleteProfile}
/>
)}
{/* Edit Wireless Interface Dialog */}
<WirelessEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
entry={editingEntry}
isV7={isV7}
securityProfiles={securityProfiles}
onSave={handleSaveInterface}
/>
{/* Security Profile Dialog */}
{!isV7 && (
<SecurityProfileDialog
open={secProfileDialogOpen}
onOpenChange={setSecProfileDialogOpen}
profile={editingProfile}
onSave={handleSaveProfile}
/>
)}
{/* Change Preview Modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={pendingChanges}
applyMode={applyMode}
onConfirm={applyChanges}
isApplying={isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Wireless Interfaces Table
// ---------------------------------------------------------------------------
function WirelessInterfacesTable({
entries,
isLoading,
isV7,
onEdit,
onToggle,
onDelete,
}: {
entries: Record<string, string>[]
isLoading: boolean
isV7: boolean
onEdit: (entry: Record<string, string>) => void
onToggle: (entry: Record<string, string>) => void
onDelete: (entry: Record<string, string>) => void
}) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading wireless interfaces...
</div>
)
}
if (entries.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-text-secondary gap-2">
<Wifi className="h-8 w-8 opacity-40" />
<p className="text-sm">No wireless interfaces detected on this device.</p>
<p className="text-xs text-text-muted">This device may not have WiFi hardware.</p>
</div>
)
}
return (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-elevated/50 border-b border-border">
<th className="text-left px-3 py-2 text-text-secondary font-medium">Status</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Name</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">SSID</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Band</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Channel Width</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Frequency</th>
{!isV7 && (
<th className="text-left px-3 py-2 text-text-secondary font-medium">Security</th>
)}
<th className="text-right px-3 py-2 text-text-secondary font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry, i) => {
const isDisabled = entry.disabled === 'true' || entry.disabled === 'yes'
// RouterOS 7 may nest ssid under configuration
const ssid = entry.ssid || entry['configuration.ssid'] || entry['configuration'] || ''
return (
<tr
key={entry['.id'] || i}
className="border-b border-border last:border-0 hover:bg-elevated/30 transition-colors"
>
<td className="px-3 py-2">
<span
className={cn(
'inline-block h-2 w-2 rounded-full',
isDisabled ? 'bg-text-muted' : 'bg-success',
)}
title={isDisabled ? 'Disabled' : 'Enabled'}
/>
</td>
<td className="px-3 py-2 text-text-primary font-medium">{entry.name}</td>
<td className="px-3 py-2 font-mono text-text-primary">{ssid || '-'}</td>
<td className="px-3 py-2 text-text-secondary text-xs">{entry.band || '-'}</td>
<td className="px-3 py-2 text-text-secondary text-xs">{entry['channel-width'] || '-'}</td>
<td className="px-3 py-2 text-text-secondary text-xs">{entry.frequency || '-'}</td>
{!isV7 && (
<td className="px-3 py-2 text-text-secondary text-xs">
{entry['security-profile'] || '-'}
</td>
)}
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => onEdit(entry)} title="Edit">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onToggle(entry)}
title={isDisabled ? 'Enable' : 'Disable'}
>
{isDisabled ? (
<Power className="h-3.5 w-3.5 text-success" />
) : (
<PowerOff className="h-3.5 w-3.5 text-text-muted" />
)}
</Button>
<Button variant="ghost" size="icon" onClick={() => onDelete(entry)} title="Delete">
<Trash2 className="h-3.5 w-3.5 text-error" />
</Button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// ---------------------------------------------------------------------------
// Security Profiles Table (RouterOS 6 only)
// ---------------------------------------------------------------------------
function SecurityProfilesTable({
entries,
isLoading,
onEdit,
onAdd,
onDelete,
}: {
entries: Record<string, string>[]
isLoading: boolean
onEdit: (profile: Record<string, string>) => void
onAdd: () => void
onDelete: (profile: Record<string, string>) => void
}) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-text-secondary">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading security profiles...
</div>
)
}
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={onAdd} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add Profile
</Button>
</div>
{entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-secondary gap-2">
<Shield className="h-8 w-8 opacity-40" />
<p className="text-sm">No security profiles configured.</p>
</div>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-elevated/50 border-b border-border">
<th className="text-left px-3 py-2 text-text-secondary font-medium">Name</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Auth Types</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Mode</th>
<th className="text-right px-3 py-2 text-text-secondary font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((profile, i) => (
<tr
key={profile['.id'] || i}
className="border-b border-border last:border-0 hover:bg-elevated/30 transition-colors"
>
<td className="px-3 py-2 text-text-primary font-medium">{profile.name}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{(profile['authentication-types'] || '').split(',').filter(Boolean).map((t) => (
<Badge key={t} className="text-[10px]">{t.trim()}</Badge>
))}
{!profile['authentication-types'] && (
<span className="text-text-muted text-xs">none</span>
)}
</div>
</td>
<td className="px-3 py-2 text-text-secondary text-xs">{profile.mode || '-'}</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => onEdit(profile)} title="Edit">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onDelete(profile)} title="Delete">
<Trash2 className="h-3.5 w-3.5 text-error" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Wireless Edit Dialog
// ---------------------------------------------------------------------------
function WirelessEditDialog({
open,
onOpenChange,
entry,
isV7,
securityProfiles,
onSave,
}: {
open: boolean
onOpenChange: (open: boolean) => void
entry: Record<string, string> | null
isV7: boolean
securityProfiles: Record<string, string>[]
onSave: (data: WirelessFormData) => void
}) {
const [showPassphrase, setShowPassphrase] = useState(false)
const ssid = entry?.ssid || entry?.['configuration.ssid'] || ''
const [formData, setFormData] = useState<WirelessFormData>({
ssid: '',
band: '',
'channel-width': '',
frequency: '',
'security-profile': '',
disabled: 'no',
'security.passphrase': '',
})
// Reset form when entry changes
const entryId = entry?.['.id'] || ''
useState(() => {
if (entry) {
setFormData({
ssid: entry.ssid || entry['configuration.ssid'] || '',
band: entry.band || '',
'channel-width': entry['channel-width'] || '',
frequency: entry.frequency || '',
'security-profile': entry['security-profile'] || '',
disabled: entry.disabled || 'no',
'security.passphrase': entry['security.passphrase'] || '',
})
}
})
// Use effect-like pattern to reset form on dialog open
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (nextOpen && entry) {
setFormData({
ssid: entry.ssid || entry['configuration.ssid'] || '',
band: entry.band || '',
'channel-width': entry['channel-width'] || '',
frequency: entry.frequency || '',
'security-profile': entry['security-profile'] || '',
disabled: entry.disabled || 'no',
'security.passphrase': entry['security.passphrase'] || '',
})
setShowPassphrase(false)
}
onOpenChange(nextOpen)
}, [entry, onOpenChange])
const updateField = useCallback((field: keyof WirelessFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}, [])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Wireless Interface</DialogTitle>
<DialogDescription>
{entry?.name ? `Editing "${entry.name}"` : 'Edit wireless settings'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* SSID */}
<div className="space-y-1.5">
<Label htmlFor="wifi-ssid">SSID</Label>
<Input
id="wifi-ssid"
value={formData.ssid}
onChange={(e) => updateField('ssid', e.target.value)}
placeholder="Network name"
className="font-mono"
/>
</div>
{/* Band */}
<div className="space-y-1.5">
<Label>Band</Label>
<Select value={formData.band} onValueChange={(v) => updateField('band', v)}>
<SelectTrigger>
<SelectValue placeholder="Select band" />
</SelectTrigger>
<SelectContent>
{BAND_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Channel Width */}
<div className="space-y-1.5">
<Label>Channel Width</Label>
<Select value={formData['channel-width']} onValueChange={(v) => updateField('channel-width', v)}>
<SelectTrigger>
<SelectValue placeholder="Select channel width" />
</SelectTrigger>
<SelectContent>
{CHANNEL_WIDTH_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Frequency */}
<div className="space-y-1.5">
<Label htmlFor="wifi-freq">Frequency (optional)</Label>
<Input
id="wifi-freq"
value={formData.frequency}
onChange={(e) => updateField('frequency', e.target.value)}
placeholder="e.g. 2412, 5180"
/>
</div>
{/* Security Profile (v6) or Passphrase (v7) */}
{isV7 ? (
<div className="space-y-1.5">
<Label htmlFor="wifi-passphrase">Passphrase</Label>
<div className="relative">
<Input
id="wifi-passphrase"
type={showPassphrase ? 'text' : 'password'}
value={formData['security.passphrase']}
onChange={(e) => updateField('security.passphrase', e.target.value)}
placeholder="WiFi password"
className="pr-9"
/>
<button
type="button"
onClick={() => setShowPassphrase(!showPassphrase)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
>
{showPassphrase ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
</div>
) : (
<div className="space-y-1.5">
<Label>Security Profile</Label>
<Select
value={formData['security-profile']}
onValueChange={(v) => updateField('security-profile', v)}
>
<SelectTrigger>
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
{securityProfiles.map((sp) => (
<SelectItem key={sp['.id'] || sp.name} value={sp.name}>
{sp.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Disabled */}
<div className="flex items-center gap-2">
<input
id="wifi-disabled"
type="checkbox"
checked={formData.disabled === 'yes' || formData.disabled === 'true'}
onChange={(e) => updateField('disabled', e.target.checked ? 'yes' : 'no')}
className="rounded border-border"
/>
<Label htmlFor="wifi-disabled">Disabled</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onSave(formData)}>
Stage Change
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// Security Profile Dialog (RouterOS 6 only)
// ---------------------------------------------------------------------------
function SecurityProfileDialog({
open,
onOpenChange,
profile,
onSave,
}: {
open: boolean
onOpenChange: (open: boolean) => void
profile: Record<string, string> | null
onSave: (data: SecurityProfileFormData) => void
}) {
const [showWpa, setShowWpa] = useState(false)
const [showWpa2, setShowWpa2] = useState(false)
const isEditing = !!profile
const [formData, setFormData] = useState<SecurityProfileFormData>({
name: '',
mode: 'dynamic-keys',
'authentication-types': 'wpa2-psk',
'wpa-pre-shared-key': '',
'wpa2-pre-shared-key': '',
})
// Reset on open
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (nextOpen) {
if (profile) {
setFormData({
name: profile.name || '',
mode: profile.mode || 'dynamic-keys',
'authentication-types': profile['authentication-types'] || '',
'wpa-pre-shared-key': '',
'wpa2-pre-shared-key': '',
})
} else {
setFormData({
name: '',
mode: 'dynamic-keys',
'authentication-types': 'wpa2-psk',
'wpa-pre-shared-key': '',
'wpa2-pre-shared-key': '',
})
}
setShowWpa(false)
setShowWpa2(false)
}
onOpenChange(nextOpen)
}, [profile, onOpenChange])
const updateField = useCallback((field: keyof SecurityProfileFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}, [])
const toggleAuthType = useCallback((authType: string) => {
setFormData((prev) => {
const types = prev['authentication-types'].split(',').map((t) => t.trim()).filter(Boolean)
const idx = types.indexOf(authType)
if (idx >= 0) {
types.splice(idx, 1)
} else {
types.push(authType)
}
return { ...prev, 'authentication-types': types.join(',') }
})
}, [])
const selectedAuthTypes = formData['authentication-types'].split(',').map((t) => t.trim()).filter(Boolean)
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit Security Profile' : 'Add Security Profile'}</DialogTitle>
<DialogDescription>
{isEditing ? `Editing "${profile?.name}"` : 'Create a new wireless security profile'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="sp-name">Name *</Label>
<Input
id="sp-name"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="Profile name"
disabled={isEditing}
/>
</div>
{/* Mode */}
<div className="space-y-1.5">
<Label>Mode</Label>
<Select value={formData.mode} onValueChange={(v) => updateField('mode', v)}>
<SelectTrigger>
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
{SECURITY_MODE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Authentication Types */}
<div className="space-y-1.5">
<Label>Authentication Types</Label>
<div className="flex flex-wrap gap-2">
{AUTH_TYPE_OPTIONS.map((authType) => (
<label key={authType} className="flex items-center gap-1.5 text-sm text-text-primary">
<input
type="checkbox"
checked={selectedAuthTypes.includes(authType)}
onChange={() => toggleAuthType(authType)}
className="rounded border-border"
/>
{authType}
</label>
))}
</div>
</div>
{/* WPA Pre-Shared Key */}
<div className="space-y-1.5">
<Label htmlFor="sp-wpa-key">WPA Pre-Shared Key</Label>
<div className="relative">
<Input
id="sp-wpa-key"
type={showWpa ? 'text' : 'password'}
value={formData['wpa-pre-shared-key']}
onChange={(e) => updateField('wpa-pre-shared-key', e.target.value)}
placeholder="WPA passphrase"
className="pr-9"
/>
<button
type="button"
onClick={() => setShowWpa(!showWpa)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
>
{showWpa ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
</div>
{/* WPA2 Pre-Shared Key */}
<div className="space-y-1.5">
<Label htmlFor="sp-wpa2-key">WPA2 Pre-Shared Key</Label>
<div className="relative">
<Input
id="sp-wpa2-key"
type={showWpa2 ? 'text' : 'password'}
value={formData['wpa2-pre-shared-key']}
onChange={(e) => updateField('wpa2-pre-shared-key', e.target.value)}
placeholder="WPA2 passphrase"
className="pr-9"
/>
<button
type="button"
onClick={() => setShowWpa2(!showWpa2)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
>
{showWpa2 ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onSave(formData)} disabled={!formData.name}>
{isEditing ? 'Stage Change' : 'Stage Addition'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}