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:
282
frontend/src/components/config/AddressListPanel.tsx
Normal file
282
frontend/src/components/config/AddressListPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
505
frontend/src/components/config/AddressPanel.tsx
Normal file
505
frontend/src/components/config/AddressPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
454
frontend/src/components/config/ArpPanel.tsx
Normal file
454
frontend/src/components/config/ArpPanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
210
frontend/src/components/config/BackupTimeline.tsx
Normal file
210
frontend/src/components/config/BackupTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
211
frontend/src/components/config/BandwidthTestTool.tsx
Normal file
211
frontend/src/components/config/BandwidthTestTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
886
frontend/src/components/config/BatchConfigPanel.tsx
Normal file
886
frontend/src/components/config/BatchConfigPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
321
frontend/src/components/config/BridgePortPanel.tsx
Normal file
321
frontend/src/components/config/BridgePortPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
304
frontend/src/components/config/BridgeVlanPanel.tsx
Normal file
304
frontend/src/components/config/BridgeVlanPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
frontend/src/components/config/ChangePreviewModal.tsx
Normal file
133
frontend/src/components/config/ChangePreviewModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
frontend/src/components/config/ConfigDiffViewer.tsx
Normal file
149
frontend/src/components/config/ConfigDiffViewer.tsx
Normal 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">→</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>
|
||||
)
|
||||
}
|
||||
232
frontend/src/components/config/ConfigTab.tsx
Normal file
232
frontend/src/components/config/ConfigTab.tsx
Normal 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 ‘Backup Now’ 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>
|
||||
)
|
||||
}
|
||||
214
frontend/src/components/config/ConnTrackPanel.tsx
Normal file
214
frontend/src/components/config/ConnTrackPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
476
frontend/src/components/config/DhcpClientPanel.tsx
Normal file
476
frontend/src/components/config/DhcpClientPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
1256
frontend/src/components/config/DhcpPanel.tsx
Normal file
1256
frontend/src/components/config/DhcpPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
613
frontend/src/components/config/DnsPanel.tsx
Normal file
613
frontend/src/components/config/DnsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1362
frontend/src/components/config/FirewallPanel.tsx
Normal file
1362
frontend/src/components/config/FirewallPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1392
frontend/src/components/config/InterfacesPanel.tsx
Normal file
1392
frontend/src/components/config/InterfacesPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
314
frontend/src/components/config/IpsecPanel.tsx
Normal file
314
frontend/src/components/config/IpsecPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
320
frontend/src/components/config/ManglePanel.tsx
Normal file
320
frontend/src/components/config/ManglePanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
54
frontend/src/components/config/NetworkToolsPanel.tsx
Normal file
54
frontend/src/components/config/NetworkToolsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
216
frontend/src/components/config/PingTool.tsx
Normal file
216
frontend/src/components/config/PingTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
448
frontend/src/components/config/PoolPanel.tsx
Normal file
448
frontend/src/components/config/PoolPanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
310
frontend/src/components/config/PppPanel.tsx
Normal file
310
frontend/src/components/config/PppPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1176
frontend/src/components/config/QueuesPanel.tsx
Normal file
1176
frontend/src/components/config/QueuesPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
140
frontend/src/components/config/RestoreButton.tsx
Normal file
140
frontend/src/components/config/RestoreButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
180
frontend/src/components/config/RestorePreview.tsx
Normal file
180
frontend/src/components/config/RestorePreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/config/RollbackAlert.tsx
Normal file
67
frontend/src/components/config/RollbackAlert.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
500
frontend/src/components/config/RoutesPanel.tsx
Normal file
500
frontend/src/components/config/RoutesPanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
57
frontend/src/components/config/SafetyToggle.tsx
Normal file
57
frontend/src/components/config/SafetyToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
604
frontend/src/components/config/ScriptsPanel.tsx
Normal file
604
frontend/src/components/config/ScriptsPanel.tsx
Normal 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 "Hello from script""
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
355
frontend/src/components/config/ServicesPanel.tsx
Normal file
355
frontend/src/components/config/ServicesPanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
360
frontend/src/components/config/SnmpPanel.tsx
Normal file
360
frontend/src/components/config/SnmpPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
339
frontend/src/components/config/SwitchPortManager.tsx
Normal file
339
frontend/src/components/config/SwitchPortManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
477
frontend/src/components/config/SystemPanel.tsx
Normal file
477
frontend/src/components/config/SystemPanel.tsx
Normal 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'}`
|
||||
}
|
||||
247
frontend/src/components/config/TorchTool.tsx
Normal file
247
frontend/src/components/config/TorchTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
189
frontend/src/components/config/TracerouteTool.tsx
Normal file
189
frontend/src/components/config/TracerouteTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
416
frontend/src/components/config/UsersPanel.tsx
Normal file
416
frontend/src/components/config/UsersPanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
987
frontend/src/components/config/WifiPanel.tsx
Normal file
987
frontend/src/components/config/WifiPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user