/** * QueuesPanel -- Queue/bandwidth management with simple queues and queue trees. * * Simple Queues: /queue/simple -- name, target, max-limit (upload/download), burst, priority * Queue Trees: /queue/tree -- parent-child hierarchy with packet marks * * Bandwidth values displayed in human-readable format (10M -> 10 Mbps). */ import { useState, useMemo, useCallback } from 'react' import { Gauge, Plus, Pencil, Trash2, Power, PowerOff, ArrowUp, ArrowDown, GitBranch, 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, useConfigPanel } from '@/hooks/useConfigPanel' import { SafetyToggle } from '@/components/config/SafetyToggle' import { ChangePreviewModal } from '@/components/config/ChangePreviewModal' import type { ConfigPanelProps, ConfigChange } from '@/lib/configPanelTypes' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type SubTab = 'simple' | 'tree' interface SimpleQueueFormData { name: string target: string 'max-limit-upload': string 'max-limit-download': string 'burst-limit-upload': string 'burst-limit-download': string 'burst-threshold-upload': string 'burst-threshold-download': string 'burst-time': string priority: string disabled: string } interface QueueTreeFormData { name: string parent: string 'packet-mark': string queue: string priority: string 'max-limit': string } interface TreeNode { entry: Record children: TreeNode[] depth: number } // --------------------------------------------------------------------------- // Bandwidth Parsing Helpers // --------------------------------------------------------------------------- /** Parse RouterOS bandwidth string to human-readable format */ function formatBandwidth(bw: string): string { if (!bw || bw === '0') return '0' const upper = bw.toUpperCase() if (upper.endsWith('G')) return `${bw.slice(0, -1)} Gbps` if (upper.endsWith('M')) return `${bw.slice(0, -1)} Mbps` if (upper.endsWith('K')) return `${bw.slice(0, -1)} Kbps` // Assume raw number is bps const num = parseInt(bw, 10) if (isNaN(num)) return bw if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} Gbps` if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)} Mbps` if (num >= 1_000) return `${(num / 1_000).toFixed(1)} Kbps` return `${num} bps` } /** Parse max-limit "upload/download" format into parts */ function parseMaxLimit(maxLimit: string): { upload: string; download: string } { if (!maxLimit) return { upload: '0', download: '0' } const parts = maxLimit.split('/') return { upload: parts[0] || '0', download: parts[1] || parts[0] || '0', } } /** Parse bandwidth string to numeric bytes for progress bar calculation */ function bandwidthToBytes(bw: string): number { if (!bw || bw === '0') return 0 const upper = bw.toUpperCase().trim() const num = parseFloat(upper) if (isNaN(num)) return 0 if (upper.endsWith('G')) return num * 1_000_000_000 if (upper.endsWith('M')) return num * 1_000_000 if (upper.endsWith('K')) return num * 1_000 return num } /** Priority badge color: 1=highest(red-ish) to 8=lowest(muted) */ function priorityColor(priority: string): string { const p = parseInt(priority, 10) || 8 if (p <= 2) return 'bg-error/20 text-error border-error/40' if (p <= 4) return 'bg-warning/20 text-warning border-warning/40' if (p <= 6) return 'bg-accent/20 text-accent border-accent/40' return 'bg-elevated text-text-muted border-border' } // --------------------------------------------------------------------------- // Tree Builder // --------------------------------------------------------------------------- function buildTree(entries: Record[]): TreeNode[] { const nodeMap = new Map() const roots: TreeNode[] = [] // Create nodes for (const entry of entries) { nodeMap.set(entry.name, { entry, children: [], depth: 0 }) } // Build hierarchy for (const entry of entries) { const node = nodeMap.get(entry.name)! const parentName = entry.parent if (parentName && parentName !== 'global' && nodeMap.has(parentName)) { const parentNode = nodeMap.get(parentName)! parentNode.children.push(node) } else { roots.push(node) } } // Set depths function setDepth(node: TreeNode, depth: number) { node.depth = depth for (const child of node.children) { setDepth(child, depth + 1) } } for (const root of roots) { setDepth(root, 0) } return roots } /** Flatten tree for rendering */ function flattenTree(nodes: TreeNode[]): TreeNode[] { const result: TreeNode[] = [] function walk(node: TreeNode) { result.push(node) for (const child of node.children) { walk(child) } } for (const n of nodes) walk(n) return result } // --------------------------------------------------------------------------- // QueuesPanel Component // --------------------------------------------------------------------------- export function QueuesPanel({ tenantId, deviceId, active }: ConfigPanelProps) { const [subTab, setSubTab] = useState('simple') const [simpleDialogOpen, setSimpleDialogOpen] = useState(false) const [editingSimple, setEditingSimple] = useState | null>(null) const [treeDialogOpen, setTreeDialogOpen] = useState(false) const [editingTree, setEditingTree] = useState | null>(null) const [previewOpen, setPreviewOpen] = useState(false) // Data loading const { entries: simpleQueues, isLoading: loadingSimple, error: simpleError } = useConfigBrowse( tenantId, deviceId, '/queue/simple', { enabled: active }, ) const { entries: treeQueues, isLoading: loadingTree, error: treeError } = useConfigBrowse( tenantId, deviceId, '/queue/tree', { enabled: active }, ) // Config panel state const { pendingChanges, applyMode, setApplyMode, addChange, clearChanges, applyChanges, isApplying, } = useConfigPanel(tenantId, deviceId, 'queues') // Compute max bandwidth for progress bar baseline const maxBandwidth = useMemo(() => { let max = 100_000_000 // 100M default baseline for (const q of simpleQueues) { const { upload, download } = parseMaxLimit(q['max-limit'] || '') max = Math.max(max, bandwidthToBytes(upload), bandwidthToBytes(download)) } return max }, [simpleQueues]) // Tree structure const treeNodes = useMemo(() => buildTree(treeQueues), [treeQueues]) const flatNodes = useMemo(() => flattenTree(treeNodes), [treeNodes]) // --------------------------------------------------------------------------- // Simple Queue Handlers // --------------------------------------------------------------------------- const handleAddSimple = useCallback(() => { setEditingSimple(null) setSimpleDialogOpen(true) }, []) const handleEditSimple = useCallback((entry: Record) => { setEditingSimple(entry) setSimpleDialogOpen(true) }, []) const handleToggleSimple = useCallback((entry: Record) => { const isDisabled = entry.disabled === 'true' || entry.disabled === 'yes' addChange({ operation: 'set', path: '/queue/simple', entryId: entry['.id'], properties: { disabled: isDisabled ? 'no' : 'yes' }, description: `${isDisabled ? 'Enable' : 'Disable'} simple queue "${entry.name}"`, }) }, [addChange]) const handleDeleteSimple = useCallback((entry: Record) => { addChange({ operation: 'remove', path: '/queue/simple', entryId: entry['.id'], properties: {}, description: `Remove simple queue "${entry.name}"`, }) }, [addChange]) const handleSaveSimple = useCallback((formData: SimpleQueueFormData) => { const maxLimit = `${formData['max-limit-upload'] || '0'}/${formData['max-limit-download'] || '0'}` const properties: Record = { name: formData.name, target: formData.target, 'max-limit': maxLimit, priority: formData.priority || '8', disabled: formData.disabled, } // Optional burst fields if (formData['burst-limit-upload'] || formData['burst-limit-download']) { properties['burst-limit'] = `${formData['burst-limit-upload'] || '0'}/${formData['burst-limit-download'] || '0'}` } if (formData['burst-threshold-upload'] || formData['burst-threshold-download']) { properties['burst-threshold'] = `${formData['burst-threshold-upload'] || '0'}/${formData['burst-threshold-download'] || '0'}` } if (formData['burst-time']) { properties['burst-time'] = formData['burst-time'] } if (editingSimple) { addChange({ operation: 'set', path: '/queue/simple', entryId: editingSimple['.id'], properties, description: `Update simple queue "${formData.name}" (limit: ${maxLimit})`, }) } else { addChange({ operation: 'add', path: '/queue/simple', properties, description: `Add simple queue "${formData.name}" (limit: ${maxLimit})`, }) } setSimpleDialogOpen(false) setEditingSimple(null) }, [editingSimple, addChange]) // --------------------------------------------------------------------------- // Queue Tree Handlers // --------------------------------------------------------------------------- const handleAddTree = useCallback(() => { setEditingTree(null) setTreeDialogOpen(true) }, []) const handleEditTree = useCallback((entry: Record) => { setEditingTree(entry) setTreeDialogOpen(true) }, []) const handleDeleteTree = useCallback((entry: Record) => { addChange({ operation: 'remove', path: '/queue/tree', entryId: entry['.id'], properties: {}, description: `Remove queue tree entry "${entry.name}"`, }) }, [addChange]) const handleSaveTree = useCallback((formData: QueueTreeFormData) => { const properties: Record = { name: formData.name, parent: formData.parent || 'global', priority: formData.priority || '8', } if (formData['packet-mark']) properties['packet-mark'] = formData['packet-mark'] if (formData.queue) properties.queue = formData.queue if (formData['max-limit']) properties['max-limit'] = formData['max-limit'] if (editingTree) { addChange({ operation: 'set', path: '/queue/tree', entryId: editingTree['.id'], properties, description: `Update queue tree "${formData.name}"`, }) } else { addChange({ operation: 'add', path: '/queue/tree', properties, description: `Add queue tree "${formData.name}" (parent: ${formData.parent || 'global'})`, }) } setTreeDialogOpen(false) setEditingTree(null) }, [editingTree, addChange]) // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- const error = simpleError || treeError if (error) { return (
Failed to load queue data: {error.message}
) } return (
{/* Header */}

Queue Management

{pendingChanges.length > 0 && (
{pendingChanges.length} pending
)}
{/* Sub-tabs */}
{/* Tab Content */} {subTab === 'simple' && ( )} {subTab === 'tree' && ( )} {/* Simple Queue Dialog */} {/* Queue Tree Dialog */} q.name).filter(Boolean)} onSave={handleSaveTree} /> {/* Change Preview Modal */}
) } // --------------------------------------------------------------------------- // Simple Queues Table // --------------------------------------------------------------------------- function SimpleQueuesTable({ entries, isLoading, maxBandwidth, onAdd, onEdit, onToggle, onDelete, }: { entries: Record[] isLoading: boolean maxBandwidth: number onAdd: () => void onEdit: (entry: Record) => void onToggle: (entry: Record) => void onDelete: (entry: Record) => void }) { if (isLoading) { return (
Loading simple queues...
) } return (
{entries.length === 0 ? (

No simple queues configured.

Add a queue to manage bandwidth limits.

) : (
{entries.map((entry, i) => { const isDisabled = entry.disabled === 'true' || entry.disabled === 'yes' const { upload, download } = parseMaxLimit(entry['max-limit'] || '') const uploadBytes = bandwidthToBytes(upload) const downloadBytes = bandwidthToBytes(download) const uploadPct = maxBandwidth > 0 ? Math.min((uploadBytes / maxBandwidth) * 100, 100) : 0 const downloadPct = maxBandwidth > 0 ? Math.min((downloadBytes / maxBandwidth) * 100, 100) : 0 const burstLimit = entry['burst-limit'] || '' return ( ) })}
Status Name Target Max Limit Bandwidth Burst Priority Actions
{entry.name} {entry.target || '-'}
{formatBandwidth(upload)} {formatBandwidth(download)}
{burstLimit || '-'} {entry.priority || '8'}
)}
) } // --------------------------------------------------------------------------- // Queue Tree View // --------------------------------------------------------------------------- function QueueTreeView({ flatNodes, isLoading, onAdd, onEdit, onDelete, }: { flatNodes: TreeNode[] isLoading: boolean onAdd: () => void onEdit: (entry: Record) => void onDelete: (entry: Record) => void }) { if (isLoading) { return (
Loading queue trees...
) } return (
{flatNodes.length === 0 ? (

No queue trees configured.

Queue trees provide hierarchical bandwidth management.

) : (
{flatNodes.map((node, i) => { const entry = node.entry return ( ) })}
Name Parent Packet Mark Queue Priority Max Limit Actions
{node.depth > 0 && ( )} {entry.name}
{entry.parent || 'global'} {entry['packet-mark'] || '-'} {entry.queue || 'default'} {entry.priority || '8'} {entry['max-limit'] ? formatBandwidth(entry['max-limit']) : '-'}
)}
) } // --------------------------------------------------------------------------- // Simple Queue Dialog // --------------------------------------------------------------------------- function SimpleQueueDialog({ open, onOpenChange, entry, onSave, }: { open: boolean onOpenChange: (open: boolean) => void entry: Record | null onSave: (data: SimpleQueueFormData) => void }) { const isEditing = !!entry const [formData, setFormData] = useState({ name: '', target: '', 'max-limit-upload': '', 'max-limit-download': '', 'burst-limit-upload': '', 'burst-limit-download': '', 'burst-threshold-upload': '', 'burst-threshold-download': '', 'burst-time': '', priority: '8', disabled: 'no', }) const handleOpenChange = useCallback((nextOpen: boolean) => { if (nextOpen) { if (entry) { const { upload: maxUp, download: maxDown } = parseMaxLimit(entry['max-limit'] || '') const { upload: burstUp, download: burstDown } = parseMaxLimit(entry['burst-limit'] || '') const { upload: threshUp, download: threshDown } = parseMaxLimit(entry['burst-threshold'] || '') setFormData({ name: entry.name || '', target: entry.target || '', 'max-limit-upload': maxUp !== '0' ? maxUp : '', 'max-limit-download': maxDown !== '0' ? maxDown : '', 'burst-limit-upload': burstUp !== '0' ? burstUp : '', 'burst-limit-download': burstDown !== '0' ? burstDown : '', 'burst-threshold-upload': threshUp !== '0' ? threshUp : '', 'burst-threshold-download': threshDown !== '0' ? threshDown : '', 'burst-time': entry['burst-time'] || '', priority: entry.priority || '8', disabled: entry.disabled || 'no', }) } else { setFormData({ name: '', target: '', 'max-limit-upload': '', 'max-limit-download': '', 'burst-limit-upload': '', 'burst-limit-download': '', 'burst-threshold-upload': '', 'burst-threshold-download': '', 'burst-time': '', priority: '8', disabled: 'no', }) } } onOpenChange(nextOpen) }, [entry, onOpenChange]) const updateField = useCallback((field: keyof SimpleQueueFormData, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })) }, []) return ( {isEditing ? 'Edit Simple Queue' : 'Add Simple Queue'} {isEditing ? `Editing "${entry?.name}"` : 'Create a new simple queue for bandwidth management'}
{/* Name */}
updateField('name', e.target.value)} placeholder="e.g. client-01-limit" />
{/* Target */}
updateField('target', e.target.value)} placeholder="IP, subnet, or interface (e.g. 192.168.1.0/24)" />

IP address, subnet, or interface name

{/* Max Limit */}
Max Limit
updateField('max-limit-upload', e.target.value)} placeholder="e.g. 10M" className="font-mono" />
updateField('max-limit-download', e.target.value)} placeholder="e.g. 50M" className="font-mono" />
{/* Burst Limit (optional) */}
Burst Limit (optional)
updateField('burst-limit-upload', e.target.value)} placeholder="e.g. 20M" className="font-mono" />
updateField('burst-limit-download', e.target.value)} placeholder="e.g. 100M" className="font-mono" />
{/* Burst Threshold (optional) */}
Burst Threshold (optional)
updateField('burst-threshold-upload', e.target.value)} placeholder="e.g. 8M" className="font-mono" />
updateField('burst-threshold-download', e.target.value)} placeholder="e.g. 40M" className="font-mono" />
{/* Burst Time */}
updateField('burst-time', e.target.value)} placeholder="e.g. 8s" />
{/* Priority */}
{/* Disabled */}
updateField('disabled', e.target.checked ? 'yes' : 'no')} className="rounded border-border" />
) } // --------------------------------------------------------------------------- // Queue Tree Dialog // --------------------------------------------------------------------------- function QueueTreeDialog({ open, onOpenChange, entry, existingNames, onSave, }: { open: boolean onOpenChange: (open: boolean) => void entry: Record | null existingNames: string[] onSave: (data: QueueTreeFormData) => void }) { const isEditing = !!entry const [formData, setFormData] = useState({ name: '', parent: 'global', 'packet-mark': '', queue: 'default', priority: '8', 'max-limit': '', }) const handleOpenChange = useCallback((nextOpen: boolean) => { if (nextOpen) { if (entry) { setFormData({ name: entry.name || '', parent: entry.parent || 'global', 'packet-mark': entry['packet-mark'] || '', queue: entry.queue || 'default', priority: entry.priority || '8', 'max-limit': entry['max-limit'] || '', }) } else { setFormData({ name: '', parent: 'global', 'packet-mark': '', queue: 'default', priority: '8', 'max-limit': '', }) } } onOpenChange(nextOpen) }, [entry, onOpenChange]) const updateField = useCallback((field: keyof QueueTreeFormData, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })) }, []) const parentOptions = ['global', ...existingNames.filter((n) => n !== entry?.name)] return ( {isEditing ? 'Edit Queue Tree' : 'Add Queue Tree'} {isEditing ? `Editing "${entry?.name}"` : 'Create a new queue tree entry'}
{/* Name */}
updateField('name', e.target.value)} placeholder="Queue tree name" />
{/* Parent */}
{/* Packet Mark */}
updateField('packet-mark', e.target.value)} placeholder="e.g. client-download" />
{/* Queue Type */}
updateField('queue', e.target.value)} placeholder="default" />
{/* Priority */}
{/* Max Limit */}
updateField('max-limit', e.target.value)} placeholder="e.g. 10M" className="font-mono" />
) }