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