Files
the-other-dude/frontend/src/components/config/QueuesPanel.tsx
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

1177 lines
42 KiB
TypeScript

/**
* 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<string, string>
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<string, string>[]): TreeNode[] {
const nodeMap = new Map<string, TreeNode>()
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<SubTab>('simple')
const [simpleDialogOpen, setSimpleDialogOpen] = useState(false)
const [editingSimple, setEditingSimple] = useState<Record<string, string> | null>(null)
const [treeDialogOpen, setTreeDialogOpen] = useState(false)
const [editingTree, setEditingTree] = useState<Record<string, string> | 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<string, string>) => {
setEditingSimple(entry)
setSimpleDialogOpen(true)
}, [])
const handleToggleSimple = useCallback((entry: Record<string, string>) => {
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<string, string>) => {
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<string, string> = {
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<string, string>) => {
setEditingTree(entry)
setTreeDialogOpen(true)
}, [])
const handleDeleteTree = useCallback((entry: Record<string, string>) => {
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<string, string> = {
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 (
<div className="flex items-center gap-2 p-6 text-error">
<AlertCircle className="h-4 w-4" />
<span>Failed to load queue data: {error.message}</span>
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-accent" />
<h3 className="text-sm font-semibold text-text-primary">Queue Management</h3>
</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('simple')}
className={cn(
'px-3 py-1.5 text-sm font-medium border-b-2 transition-colors',
subTab === 'simple'
? 'border-accent text-accent'
: 'border-transparent text-text-secondary hover:text-text-primary',
)}
>
<span className="flex items-center gap-1.5">
<Gauge className="h-3.5 w-3.5" />
Simple Queues
</span>
</button>
<button
onClick={() => setSubTab('tree')}
className={cn(
'px-3 py-1.5 text-sm font-medium border-b-2 transition-colors',
subTab === 'tree'
? 'border-accent text-accent'
: 'border-transparent text-text-secondary hover:text-text-primary',
)}
>
<span className="flex items-center gap-1.5">
<GitBranch className="h-3.5 w-3.5" />
Queue Trees
</span>
</button>
</div>
{/* Tab Content */}
{subTab === 'simple' && (
<SimpleQueuesTable
entries={simpleQueues}
isLoading={loadingSimple}
maxBandwidth={maxBandwidth}
onAdd={handleAddSimple}
onEdit={handleEditSimple}
onToggle={handleToggleSimple}
onDelete={handleDeleteSimple}
/>
)}
{subTab === 'tree' && (
<QueueTreeView
flatNodes={flatNodes}
isLoading={loadingTree}
onAdd={handleAddTree}
onEdit={handleEditTree}
onDelete={handleDeleteTree}
/>
)}
{/* Simple Queue Dialog */}
<SimpleQueueDialog
open={simpleDialogOpen}
onOpenChange={setSimpleDialogOpen}
entry={editingSimple}
onSave={handleSaveSimple}
/>
{/* Queue Tree Dialog */}
<QueueTreeDialog
open={treeDialogOpen}
onOpenChange={setTreeDialogOpen}
entry={editingTree}
existingNames={treeQueues.map((q) => q.name).filter(Boolean)}
onSave={handleSaveTree}
/>
{/* Change Preview Modal */}
<ChangePreviewModal
open={previewOpen}
onOpenChange={setPreviewOpen}
changes={pendingChanges}
applyMode={applyMode}
onConfirm={applyChanges}
isApplying={isApplying}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Simple Queues Table
// ---------------------------------------------------------------------------
function SimpleQueuesTable({
entries,
isLoading,
maxBandwidth,
onAdd,
onEdit,
onToggle,
onDelete,
}: {
entries: Record<string, string>[]
isLoading: boolean
maxBandwidth: number
onAdd: () => void
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 simple queues...
</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 Queue
</Button>
</div>
{entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-secondary gap-2">
<Gauge className="h-8 w-8 opacity-40" />
<p className="text-sm">No simple queues configured.</p>
<p className="text-xs text-text-muted">Add a queue to manage bandwidth limits.</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">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">Target</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Max Limit</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Bandwidth</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Burst</th>
<th className="text-center px-3 py-2 text-text-secondary font-medium">Priority</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'
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 (
<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-secondary text-xs">{entry.target || '-'}</td>
<td className="px-3 py-2">
<div className="flex flex-col gap-0.5 font-mono text-xs">
<span className="flex items-center gap-1 text-text-primary">
<ArrowUp className="h-3 w-3 text-success" />
{formatBandwidth(upload)}
</span>
<span className="flex items-center gap-1 text-text-primary">
<ArrowDown className="h-3 w-3 text-accent" />
{formatBandwidth(download)}
</span>
</div>
</td>
<td className="px-3 py-2">
<div className="flex flex-col gap-1 min-w-[80px]">
<div className="flex items-center gap-1">
<ArrowUp className="h-2.5 w-2.5 text-success shrink-0" />
<div className="h-1.5 flex-1 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-success rounded-full transition-all"
style={{ width: `${uploadPct}%` }}
/>
</div>
</div>
<div className="flex items-center gap-1">
<ArrowDown className="h-2.5 w-2.5 text-accent shrink-0" />
<div className="h-1.5 flex-1 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all"
style={{ width: `${downloadPct}%` }}
/>
</div>
</div>
</div>
</td>
<td className="px-3 py-2 text-text-muted text-xs font-mono">
{burstLimit || '-'}
</td>
<td className="px-3 py-2 text-center">
<Badge className={cn('text-[10px]', priorityColor(entry.priority || '8'))}>
{entry.priority || '8'}
</Badge>
</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>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Queue Tree View
// ---------------------------------------------------------------------------
function QueueTreeView({
flatNodes,
isLoading,
onAdd,
onEdit,
onDelete,
}: {
flatNodes: TreeNode[]
isLoading: boolean
onAdd: () => void
onEdit: (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 queue trees...
</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 Queue Tree
</Button>
</div>
{flatNodes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-secondary gap-2">
<GitBranch className="h-8 w-8 opacity-40" />
<p className="text-sm">No queue trees configured.</p>
<p className="text-xs text-text-muted">Queue trees provide hierarchical bandwidth management.</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">Parent</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Packet Mark</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Queue</th>
<th className="text-center px-3 py-2 text-text-secondary font-medium">Priority</th>
<th className="text-left px-3 py-2 text-text-secondary font-medium">Max Limit</th>
<th className="text-right px-3 py-2 text-text-secondary font-medium">Actions</th>
</tr>
</thead>
<tbody>
{flatNodes.map((node, i) => {
const entry = node.entry
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">
<div
className="flex items-center gap-1"
style={{ paddingLeft: `${node.depth * 16}px` }}
>
{node.depth > 0 && (
<span className="border-l-2 border-border h-4 mr-1" />
)}
<span className="text-text-primary font-medium">{entry.name}</span>
</div>
</td>
<td className="px-3 py-2 text-text-secondary text-xs">
{entry.parent || 'global'}
</td>
<td className="px-3 py-2 font-mono text-text-secondary text-xs">
{entry['packet-mark'] || '-'}
</td>
<td className="px-3 py-2 text-text-secondary text-xs">
{entry.queue || 'default'}
</td>
<td className="px-3 py-2 text-center">
<Badge className={cn('text-[10px]', priorityColor(entry.priority || '8'))}>
{entry.priority || '8'}
</Badge>
</td>
<td className="px-3 py-2 font-mono text-text-primary text-xs">
{entry['max-limit'] ? formatBandwidth(entry['max-limit']) : '-'}
</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={() => onDelete(entry)} title="Delete">
<Trash2 className="h-3.5 w-3.5 text-error" />
</Button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Simple Queue Dialog
// ---------------------------------------------------------------------------
function SimpleQueueDialog({
open,
onOpenChange,
entry,
onSave,
}: {
open: boolean
onOpenChange: (open: boolean) => void
entry: Record<string, string> | null
onSave: (data: SimpleQueueFormData) => void
}) {
const isEditing = !!entry
const [formData, setFormData] = useState<SimpleQueueFormData>({
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit Simple Queue' : 'Add Simple Queue'}</DialogTitle>
<DialogDescription>
{isEditing ? `Editing "${entry?.name}"` : 'Create a new simple queue for bandwidth management'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2 max-h-[60vh] overflow-y-auto">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="sq-name">Name *</Label>
<Input
id="sq-name"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="e.g. client-01-limit"
/>
</div>
{/* Target */}
<div className="space-y-1.5">
<Label htmlFor="sq-target">Target *</Label>
<Input
id="sq-target"
value={formData.target}
onChange={(e) => updateField('target', e.target.value)}
placeholder="IP, subnet, or interface (e.g. 192.168.1.0/24)"
/>
<p className="text-[10px] text-text-muted">IP address, subnet, or interface name</p>
</div>
{/* Max Limit */}
<fieldset className="space-y-2">
<legend className="text-xs font-medium text-text-secondary">Max Limit</legend>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="sq-max-up" className="flex items-center gap-1">
<ArrowUp className="h-3 w-3 text-success" />
Upload
</Label>
<Input
id="sq-max-up"
value={formData['max-limit-upload']}
onChange={(e) => updateField('max-limit-upload', e.target.value)}
placeholder="e.g. 10M"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label htmlFor="sq-max-down" className="flex items-center gap-1">
<ArrowDown className="h-3 w-3 text-accent" />
Download
</Label>
<Input
id="sq-max-down"
value={formData['max-limit-download']}
onChange={(e) => updateField('max-limit-download', e.target.value)}
placeholder="e.g. 50M"
className="font-mono"
/>
</div>
</div>
</fieldset>
{/* Burst Limit (optional) */}
<fieldset className="space-y-2">
<legend className="text-xs font-medium text-text-secondary">Burst Limit (optional)</legend>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="sq-burst-up" className="flex items-center gap-1">
<ArrowUp className="h-3 w-3 text-success" />
Upload
</Label>
<Input
id="sq-burst-up"
value={formData['burst-limit-upload']}
onChange={(e) => updateField('burst-limit-upload', e.target.value)}
placeholder="e.g. 20M"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label htmlFor="sq-burst-down" className="flex items-center gap-1">
<ArrowDown className="h-3 w-3 text-accent" />
Download
</Label>
<Input
id="sq-burst-down"
value={formData['burst-limit-download']}
onChange={(e) => updateField('burst-limit-download', e.target.value)}
placeholder="e.g. 100M"
className="font-mono"
/>
</div>
</div>
</fieldset>
{/* Burst Threshold (optional) */}
<fieldset className="space-y-2">
<legend className="text-xs font-medium text-text-secondary">Burst Threshold (optional)</legend>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="sq-thresh-up" className="flex items-center gap-1">
<ArrowUp className="h-3 w-3 text-success" />
Upload
</Label>
<Input
id="sq-thresh-up"
value={formData['burst-threshold-upload']}
onChange={(e) => updateField('burst-threshold-upload', e.target.value)}
placeholder="e.g. 8M"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label htmlFor="sq-thresh-down" className="flex items-center gap-1">
<ArrowDown className="h-3 w-3 text-accent" />
Download
</Label>
<Input
id="sq-thresh-down"
value={formData['burst-threshold-download']}
onChange={(e) => updateField('burst-threshold-download', e.target.value)}
placeholder="e.g. 40M"
className="font-mono"
/>
</div>
</div>
</fieldset>
{/* Burst Time */}
<div className="space-y-1.5">
<Label htmlFor="sq-burst-time">Burst Time (optional)</Label>
<Input
id="sq-burst-time"
value={formData['burst-time']}
onChange={(e) => updateField('burst-time', e.target.value)}
placeholder="e.g. 8s"
/>
</div>
{/* Priority */}
<div className="space-y-1.5">
<Label>Priority (1=highest, 8=lowest)</Label>
<Select value={formData.priority} onValueChange={(v) => updateField('priority', v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 8 }, (_, i) => i + 1).map((p) => (
<SelectItem key={p} value={String(p)}>
{p} {p === 1 ? '(highest)' : p === 8 ? '(lowest)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Disabled */}
<div className="flex items-center gap-2">
<input
id="sq-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="sq-disabled">Disabled</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => onSave(formData)}
disabled={!formData.name || !formData.target}
>
{isEditing ? 'Stage Change' : 'Stage Addition'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// Queue Tree Dialog
// ---------------------------------------------------------------------------
function QueueTreeDialog({
open,
onOpenChange,
entry,
existingNames,
onSave,
}: {
open: boolean
onOpenChange: (open: boolean) => void
entry: Record<string, string> | null
existingNames: string[]
onSave: (data: QueueTreeFormData) => void
}) {
const isEditing = !!entry
const [formData, setFormData] = useState<QueueTreeFormData>({
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit Queue Tree' : 'Add Queue Tree'}</DialogTitle>
<DialogDescription>
{isEditing ? `Editing "${entry?.name}"` : 'Create a new queue tree entry'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="qt-name">Name *</Label>
<Input
id="qt-name"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="Queue tree name"
/>
</div>
{/* Parent */}
<div className="space-y-1.5">
<Label>Parent</Label>
<Select value={formData.parent} onValueChange={(v) => updateField('parent', v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{parentOptions.map((name) => (
<SelectItem key={name} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Packet Mark */}
<div className="space-y-1.5">
<Label htmlFor="qt-mark">Packet Mark</Label>
<Input
id="qt-mark"
value={formData['packet-mark']}
onChange={(e) => updateField('packet-mark', e.target.value)}
placeholder="e.g. client-download"
/>
</div>
{/* Queue Type */}
<div className="space-y-1.5">
<Label htmlFor="qt-queue">Queue Type</Label>
<Input
id="qt-queue"
value={formData.queue}
onChange={(e) => updateField('queue', e.target.value)}
placeholder="default"
/>
</div>
{/* Priority */}
<div className="space-y-1.5">
<Label>Priority (1=highest, 8=lowest)</Label>
<Select value={formData.priority} onValueChange={(v) => updateField('priority', v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 8 }, (_, i) => i + 1).map((p) => (
<SelectItem key={p} value={String(p)}>
{p} {p === 1 ? '(highest)' : p === 8 ? '(lowest)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Max Limit */}
<div className="space-y-1.5">
<Label htmlFor="qt-max">Max Limit</Label>
<Input
id="qt-max"
value={formData['max-limit']}
onChange={(e) => updateField('max-limit', e.target.value)}
placeholder="e.g. 10M"
className="font-mono"
/>
</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>
)
}