Files
the-other-dude/frontend/src/components/config/TorchTool.tsx
Jason Staack b39014ef47 refactor(ui): migrate all components to Warm Precision token names
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:49:37 -05:00

239 lines
8.9 KiB
TypeScript

/**
* 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 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-panel 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-panel 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-panel 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-panel p-6 text-center text-sm text-text-muted">
No traffic captured. Try a different interface or remove filters.
</div>
)}
</div>
)
}