File transfer
This commit is contained in:
103
resources/scripts/addons/advanced-admin/network/FlowGraph.tsx
Normal file
103
resources/scripts/addons/advanced-admin/network/FlowGraph.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Flow Graph — container-to-container traffic visualization
|
||||
* Uses a simple SVG force-directed layout (D3-free for bundle size)
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface Flow {
|
||||
src_server_id: number | null;
|
||||
dst_server_id: number | null;
|
||||
bytes: number;
|
||||
packets: number;
|
||||
}
|
||||
|
||||
interface Props { flows: Flow[]; }
|
||||
|
||||
export default function FlowGraph({ flows }: Props) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || flows.length === 0) return;
|
||||
|
||||
const svg = svgRef.current;
|
||||
const W = svg.clientWidth || 600;
|
||||
const H = 280;
|
||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
|
||||
// Collect unique server IDs + "Internet" (null src)
|
||||
const serverIds = new Set<string>();
|
||||
flows.forEach(f => {
|
||||
serverIds.add(f.src_server_id ? `s${f.src_server_id}` : 'internet');
|
||||
serverIds.add(f.dst_server_id ? `s${f.dst_server_id}` : 'internet');
|
||||
});
|
||||
const nodes = Array.from(serverIds);
|
||||
|
||||
// Layout: evenly spaced horizontally
|
||||
const positions: Record<string, { x: number; y: number }> = {};
|
||||
nodes.forEach((id, i) => {
|
||||
positions[id] = { x: (W / (nodes.length + 1)) * (i + 1), y: H / 2 };
|
||||
});
|
||||
|
||||
const maxBytes = Math.max(...flows.map(f => f.bytes), 1);
|
||||
|
||||
// Clear
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
|
||||
// Draw edges
|
||||
flows.forEach(f => {
|
||||
const srcId = f.src_server_id ? `s${f.src_server_id}` : 'internet';
|
||||
const dstId = f.dst_server_id ? `s${f.dst_server_id}` : 'internet';
|
||||
if (!positions[srcId] || !positions[dstId]) return;
|
||||
const src = positions[srcId];
|
||||
const dst = positions[dstId];
|
||||
const strokeW = 1 + (f.bytes / maxBytes) * 5;
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', String(src.x)); line.setAttribute('y1', String(src.y));
|
||||
line.setAttribute('x2', String(dst.x)); line.setAttribute('y2', String(dst.y));
|
||||
line.setAttribute('stroke', '#6366f1');
|
||||
line.setAttribute('stroke-width', String(strokeW));
|
||||
line.setAttribute('stroke-opacity', '0.6');
|
||||
svg.appendChild(line);
|
||||
|
||||
// Arrow marker
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||||
const midX = (src.x + dst.x) / 2;
|
||||
const midY = (src.y + dst.y) / 2;
|
||||
arrow.setAttribute('points', `${midX},${midY - 4} ${midX + 8},${midY} ${midX},${midY + 4}`);
|
||||
arrow.setAttribute('fill', '#818cf8');
|
||||
svg.appendChild(arrow);
|
||||
});
|
||||
|
||||
// Draw nodes
|
||||
nodes.forEach(id => {
|
||||
const pos = positions[id];
|
||||
const isNet = id === 'internet';
|
||||
const label = isNet ? '🌐 Internet' : `Server ${id.slice(1)}`;
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', String(pos.x)); circle.setAttribute('cy', String(pos.y));
|
||||
circle.setAttribute('r', '28');
|
||||
circle.setAttribute('fill', isNet ? '#1e3a5f' : '#1e1b4b');
|
||||
circle.setAttribute('stroke', isNet ? '#60a5fa' : '#818cf8');
|
||||
circle.setAttribute('stroke-width', '2');
|
||||
svg.appendChild(circle);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', String(pos.x)); text.setAttribute('y', String(pos.y + 5));
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.setAttribute('fill', '#e5e7eb');
|
||||
text.setAttribute('font-size', '11');
|
||||
text.textContent = label;
|
||||
svg.appendChild(text);
|
||||
});
|
||||
}, [flows]);
|
||||
|
||||
if (flows.length === 0) {
|
||||
return <div className='addon-empty'>No inter-container flows detected in the last 24h.</div>;
|
||||
}
|
||||
|
||||
return <svg ref={svgRef} className='addon-flow-graph' width='100%' height='280' />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Live Traffic Cards
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface LiveStats {
|
||||
rx_bytes: number;
|
||||
tx_bytes: number;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
interface Props { live: LiveStats | null; }
|
||||
|
||||
export default function LiveTrafficCards({ live }: Props) {
|
||||
return (
|
||||
<div className='addon-stat-grid'>
|
||||
<div className='addon-stat-card inbound'>
|
||||
<div className='addon-stat-label'>⬇ Inbound</div>
|
||||
<div className='addon-stat-value'>{live ? formatBytes(live.rx_bytes) : '—'}</div>
|
||||
</div>
|
||||
<div className='addon-stat-card outbound'>
|
||||
<div className='addon-stat-label'>⬆ Outbound</div>
|
||||
<div className='addon-stat-value'>{live ? formatBytes(live.tx_bytes) : '—'}</div>
|
||||
</div>
|
||||
<div className='addon-stat-card total'>
|
||||
<div className='addon-stat-label'>⇅ Total</div>
|
||||
<div className='addon-stat-value'>{live ? formatBytes(live.rx_bytes + live.tx_bytes) : '—'}</div>
|
||||
</div>
|
||||
<div className='addon-stat-card updated'>
|
||||
<div className='addon-stat-label'>🕐 Last Update</div>
|
||||
<div className='addon-stat-value'>{live ? new Date(live.recorded_at).toLocaleTimeString() : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
resources/scripts/addons/advanced-admin/network/NetworkTab.tsx
Normal file
125
resources/scripts/addons/advanced-admin/network/NetworkTab.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Network Traffic Dashboard — Main Tab
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import http from '@/api/http';
|
||||
import LiveTrafficCards from './LiveTrafficCards';
|
||||
import TrafficChart from './TrafficChart';
|
||||
import PortTable from './PortTable';
|
||||
import FlowGraph from './FlowGraph';
|
||||
|
||||
interface LiveStats {
|
||||
rx_bytes: number;
|
||||
tx_bytes: number;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
interface HistoryPoint {
|
||||
rx_bytes: number;
|
||||
tx_bytes: number;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
type Resolution = '5m' | '1h' | '1d';
|
||||
|
||||
export default function NetworkTab() {
|
||||
const { id: serverUuid } = useParams<{ id: string }>();
|
||||
|
||||
const [live, setLive] = useState<LiveStats | null>(null);
|
||||
const [history, setHistory] = useState<HistoryPoint[]>([]);
|
||||
const [ports, setPorts] = useState<any[]>([]);
|
||||
const [flows, setFlows] = useState<any[]>([]);
|
||||
const [resolution, setRes] = useState<Resolution>('5m');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchLive = useCallback(async () => {
|
||||
if (!serverUuid) return;
|
||||
try {
|
||||
const { data } = await http.get(`/api/client/servers/${serverUuid}/addons/network/live`);
|
||||
setLive(data.data);
|
||||
} catch {
|
||||
// silently fail on live poll errors
|
||||
}
|
||||
}, [serverUuid]);
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
if (!serverUuid) return;
|
||||
const { data } = await http.get(`/api/client/servers/${serverUuid}/addons/network/history`, {
|
||||
params: { resolution },
|
||||
});
|
||||
setHistory(data.data ?? []);
|
||||
}, [serverUuid, resolution]);
|
||||
|
||||
const fetchPorts = useCallback(async () => {
|
||||
if (!serverUuid) return;
|
||||
const { data } = await http.get(`/api/client/servers/${serverUuid}/addons/network/ports`);
|
||||
setPorts(data.data ?? []);
|
||||
}, [serverUuid]);
|
||||
|
||||
const fetchFlows = useCallback(async () => {
|
||||
if (!serverUuid) return;
|
||||
const { data } = await http.get(`/api/client/servers/${serverUuid}/addons/network/flows`);
|
||||
setFlows(data.data ?? []);
|
||||
}, [serverUuid]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([fetchHistory(), fetchPorts(), fetchFlows()])
|
||||
.catch(() => setError('Failed to load network data.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [fetchHistory, fetchPorts, fetchFlows]);
|
||||
|
||||
// Live polling — every 10s
|
||||
useEffect(() => {
|
||||
fetchLive();
|
||||
const id = setInterval(fetchLive, 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchLive]);
|
||||
|
||||
if (loading) return <div className='addon-loading'>Loading network data…</div>;
|
||||
if (error) return <div className='addon-error'>{error}</div>;
|
||||
|
||||
return (
|
||||
<div className='addon-network'>
|
||||
<h2 className='addon-section-title'>🌐 Network Traffic</h2>
|
||||
|
||||
{/* Live cards */}
|
||||
<LiveTrafficCards live={live} />
|
||||
|
||||
{/* Historical chart */}
|
||||
<div className='addon-card'>
|
||||
<div className='addon-card-header'>
|
||||
<span>Bandwidth Over Time</span>
|
||||
<div className='addon-resolution-tabs'>
|
||||
{(['5m', '1h', '1d'] as Resolution[]).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
className={`addon-tab ${resolution === r ? 'active' : ''}`}
|
||||
onClick={() => setRes(r)}
|
||||
>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TrafficChart data={history} />
|
||||
</div>
|
||||
|
||||
{/* Port breakdown */}
|
||||
<div className='addon-card'>
|
||||
<div className='addon-card-header'>Top Ports</div>
|
||||
<PortTable ports={ports} />
|
||||
</div>
|
||||
|
||||
{/* Flow graph */}
|
||||
<div className='addon-card'>
|
||||
<div className='addon-card-header'>Container Traffic Flow</div>
|
||||
<FlowGraph flows={flows} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Port Table — top ports by traffic
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Port {
|
||||
port: number;
|
||||
protocol: string;
|
||||
rx_bytes: number;
|
||||
tx_bytes: number;
|
||||
}
|
||||
|
||||
function fmt(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export default function PortTable({ ports }: { ports: Port[] }) {
|
||||
if (!ports.length) {
|
||||
return <div className='addon-empty'>Port-level data requires iptables collection (disabled by default).</div>;
|
||||
}
|
||||
return (
|
||||
<table className='addon-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Port</th><th>Proto</th><th>⬇ Inbound</th><th>⬆ Outbound</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ports.map(p => (
|
||||
<tr key={`${p.port}-${p.protocol}`}>
|
||||
<td>{p.port}</td>
|
||||
<td><span className={`addon-badge ${p.protocol}`}>{p.protocol.toUpperCase()}</span></td>
|
||||
<td>{fmt(p.rx_bytes)}</td>
|
||||
<td>{fmt(p.tx_bytes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Traffic Chart — Recharts line chart for bandwidth over time
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface DataPoint {
|
||||
rx_bytes: number;
|
||||
tx_bytes: number;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
interface Props { data: DataPoint[]; }
|
||||
|
||||
function formatTick(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
export default function TrafficChart({ data }: Props) {
|
||||
if (data.length === 0) {
|
||||
return <div className='addon-empty'>No historical data available yet.</div>;
|
||||
}
|
||||
|
||||
const chartData = data.map(d => ({
|
||||
time: new Date(d.recorded_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
rx: d.rx_bytes,
|
||||
tx: d.tx_bytes,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width='100%' height={260}>
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray='3 3' stroke='rgba(255,255,255,0.08)' />
|
||||
<XAxis dataKey='time' tick={{ fill: '#9ca3af', fontSize: 11 }} tickLine={false} />
|
||||
<YAxis tickFormatter={formatTick} tick={{ fill: '#9ca3af', fontSize: 11 }} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1f2937', border: 'none', borderRadius: 8 }}
|
||||
formatter={(v: number, name: string) => [
|
||||
`${(v / 1024 / 1024).toFixed(2)} MB`,
|
||||
name === 'rx' ? '⬇ Inbound' : '⬆ Outbound',
|
||||
]}
|
||||
/>
|
||||
<Legend formatter={(v) => v === 'rx' ? '⬇ Inbound' : '⬆ Outbound'} />
|
||||
<Line type='monotone' dataKey='rx' stroke='#34d399' dot={false} strokeWidth={2} />
|
||||
<Line type='monotone' dataKey='tx' stroke='#60a5fa' dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user