feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
247
frontend/src/components/config/TorchTool.tsx
Normal file
247
frontend/src/components/config/TorchTool.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* TorchTool -- Live traffic monitoring per interface.
|
||||
*
|
||||
* Uses /tool/torch via config editor execute.
|
||||
* Filter by src/dst address, protocol, port.
|
||||
* Auto-refresh with configurable interval.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Play, Square, Flame, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { configEditorApi } from '@/lib/configEditorApi'
|
||||
import { useConfigBrowse } from '@/hooks/useConfigPanel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
|
||||
|
||||
interface TorchEntry {
|
||||
srcAddress: string
|
||||
dstAddress: string
|
||||
protocol: string
|
||||
srcPort: string
|
||||
dstPort: string
|
||||
txRate: string
|
||||
rxRate: string
|
||||
tx: string
|
||||
rx: string
|
||||
}
|
||||
|
||||
function formatBytes(val: string): string {
|
||||
const n = parseInt(val, 10)
|
||||
if (isNaN(n)) return val || '-'
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} GB`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} KB`
|
||||
return `${n} B`
|
||||
}
|
||||
|
||||
function formatBps(val: string): string {
|
||||
const n = parseInt(val, 10)
|
||||
if (isNaN(n)) return val || '-'
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} Mbps`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} Kbps`
|
||||
return `${n} bps`
|
||||
}
|
||||
|
||||
export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
const interfaces = useConfigBrowse(tenantId, deviceId, '/interface', { enabled: active })
|
||||
const [iface, setIface] = useState('ether1')
|
||||
const [srcFilter, setSrcFilter] = useState('')
|
||||
const [dstFilter, setDstFilter] = useState('')
|
||||
const [protocolFilter, setProtocolFilter] = useState('')
|
||||
const [portFilter, setPortFilter] = useState('')
|
||||
const [autoRefresh, setAutoRefresh] = useState(false)
|
||||
const [entries, setEntries] = useState<TorchEntry[]>([])
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const torchMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const parts = ['/tool/torch', `interface=${iface}`, 'duration=3s']
|
||||
if (srcFilter) parts.push(`src-address=${srcFilter}`)
|
||||
if (dstFilter) parts.push(`dst-address=${dstFilter}`)
|
||||
if (protocolFilter) parts.push(`protocol=${protocolFilter}`)
|
||||
if (portFilter) parts.push(`port=${portFilter}`)
|
||||
return configEditorApi.execute(tenantId, deviceId, parts.join(' '))
|
||||
},
|
||||
onSuccess: (resp) => {
|
||||
if (!resp.success) { setEntries([]); return }
|
||||
const rows: TorchEntry[] = resp.data
|
||||
.filter((d) => d['src-address'] || d['dst-address'])
|
||||
.map((d) => ({
|
||||
srcAddress: d['src-address'] || '',
|
||||
dstAddress: d['dst-address'] || '',
|
||||
protocol: d['ip-protocol'] || d['protocol'] || '',
|
||||
srcPort: d['src-port'] || '',
|
||||
dstPort: d['dst-port'] || '',
|
||||
txRate: d['tx-rate'] || d['tx'] || '',
|
||||
rxRate: d['rx-rate'] || d['rx'] || '',
|
||||
tx: d['tx-packets'] || '',
|
||||
rx: d['rx-packets'] || '',
|
||||
}))
|
||||
setEntries(rows)
|
||||
},
|
||||
})
|
||||
|
||||
const handleRun = useCallback(() => {
|
||||
torchMutation.mutate()
|
||||
}, [torchMutation])
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (autoRefresh && !torchMutation.isPending) {
|
||||
timerRef.current = setInterval(() => {
|
||||
torchMutation.mutate()
|
||||
}, 5000)
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [autoRefresh, torchMutation.isPending])
|
||||
|
||||
const handleToggleAuto = useCallback(() => {
|
||||
if (autoRefresh) {
|
||||
setAutoRefresh(false)
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
} else {
|
||||
setAutoRefresh(true)
|
||||
handleRun()
|
||||
}
|
||||
}, [autoRefresh, handleRun])
|
||||
|
||||
const ifaceNames = interfaces.entries.map((e) => e['name']).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Interface</Label>
|
||||
<select
|
||||
value={iface}
|
||||
onChange={(e) => setIface(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
|
||||
>
|
||||
{ifaceNames.length > 0
|
||||
? ifaceNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))
|
||||
: <option value={iface}>{iface}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Src Address</Label>
|
||||
<Input
|
||||
value={srcFilter}
|
||||
onChange={(e) => setSrcFilter(e.target.value)}
|
||||
placeholder="any"
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Dst Address</Label>
|
||||
<Input
|
||||
value={dstFilter}
|
||||
onChange={(e) => setDstFilter(e.target.value)}
|
||||
placeholder="any"
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Protocol</Label>
|
||||
<Input
|
||||
value={protocolFilter}
|
||||
onChange={(e) => setProtocolFilter(e.target.value)}
|
||||
placeholder="any"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Port</Label>
|
||||
<Input
|
||||
value={portFilter}
|
||||
onChange={(e) => setPortFilter(e.target.value)}
|
||||
placeholder="any"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
onClick={handleRun}
|
||||
disabled={torchMutation.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{torchMutation.isPending ? (
|
||||
<><Square className="h-3.5 w-3.5" /> Capturing...</>
|
||||
) : (
|
||||
<><Play className="h-3.5 w-3.5" /> Capture</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? 'destructive' : 'outline'}
|
||||
onClick={handleToggleAuto}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', autoRefresh && 'animate-spin')} />
|
||||
{autoRefresh ? 'Stop Auto' : 'Auto Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{torchMutation.isError && (
|
||||
<div className="rounded-lg border border-error/50 bg-error/10 p-4 text-sm text-error">
|
||||
Failed to execute torch command.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
|
||||
<Flame className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-text-secondary">
|
||||
Torch — {iface} ({entries.length} flows)
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 text-text-muted">
|
||||
<th className="text-left px-3 py-2">Src Address</th>
|
||||
<th className="text-left px-3 py-2">Dst Address</th>
|
||||
<th className="text-left px-3 py-2">Proto</th>
|
||||
<th className="text-left px-3 py-2">Src Port</th>
|
||||
<th className="text-left px-3 py-2">Dst Port</th>
|
||||
<th className="text-right px-3 py-2">TX Rate</th>
|
||||
<th className="text-right px-3 py-2">RX Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono">
|
||||
{entries.map((e, i) => (
|
||||
<tr key={i} className="border-b border-border/20 last:border-0">
|
||||
<td className="px-3 py-1.5 text-text-primary">{e.srcAddress || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-text-primary">{e.dstAddress || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-text-secondary">{e.protocol || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-text-muted">{e.srcPort || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-text-muted">{e.dstPort || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-right text-accent">{formatBps(e.txRate)}</td>
|
||||
<td className="px-3 py-1.5 text-right text-info">{formatBps(e.rxRate)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 && !torchMutation.isPending && !torchMutation.isIdle && (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
|
||||
No traffic captured. Try a different interface or remove filters.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user