File transfer

This commit is contained in:
2026-06-11 20:33:44 -05:00
parent 2cb1d58264
commit b362357bcb
58 changed files with 5096 additions and 4 deletions

View 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' />;
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}