File transfer
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Autocomplete Dropdown
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Suggestion {
|
||||
command: string;
|
||||
description: string;
|
||||
plugin: string;
|
||||
dangerous: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
suggestions: Suggestion[];
|
||||
selectedIndex: number;
|
||||
onSelect: (sug: Suggestion) => void;
|
||||
}
|
||||
|
||||
export default function AutocompleteDropdown({ suggestions, selectedIndex, onSelect }: Props) {
|
||||
return (
|
||||
<ul className='addon-autocomplete-dropdown' role='listbox'>
|
||||
{suggestions.map((sug, i) => (
|
||||
<li
|
||||
key={sug.command}
|
||||
className={`addon-autocomplete-item ${i === selectedIndex ? 'active' : ''} ${sug.dangerous ? 'danger' : ''}`}
|
||||
role='option'
|
||||
aria-selected={i === selectedIndex}
|
||||
onClick={() => onSelect(sug)}
|
||||
onMouseDown={e => e.preventDefault()} // prevent blur
|
||||
>
|
||||
<span className='addon-ac-command'>/{sug.command}</span>
|
||||
{sug.dangerous && <span className='addon-ac-danger-badge'>⚠ Danger</span>}
|
||||
<span className='addon-ac-desc'>{sug.description}</span>
|
||||
<span className='addon-ac-plugin'>{sug.plugin}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
158
resources/scripts/addons/advanced-admin/console/CommandInput.tsx
Normal file
158
resources/scripts/addons/advanced-admin/console/CommandInput.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Console Command Input with Autocomplete
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import http from '@/api/http';
|
||||
import AutocompleteDropdown from './AutocompleteDropdown';
|
||||
import SyntaxHelper from './SyntaxHelper';
|
||||
import DangerCommandWarning from './DangerCommandWarning';
|
||||
|
||||
interface Suggestion {
|
||||
command: string;
|
||||
description: string;
|
||||
syntax: string;
|
||||
dangerous: boolean;
|
||||
danger_reason?: string;
|
||||
plugin: string;
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSend: (command: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DANGEROUS = ['stop','restart','ban','ban-ip','op','deop','whitelist off','save-off','kill @e','kill @a'];
|
||||
|
||||
export default function CommandInput({ onSend, disabled = false }: Props) {
|
||||
const { id: serverUuid } = useParams<{ id: string }>();
|
||||
const [value, setValue] = useState('');
|
||||
const [suggestions, setSugs]= useState<Suggestion[]>([]);
|
||||
const [selected, setSelected]= useState(-1);
|
||||
const [showDanger, setDanger]= useState<Suggestion | null>(null);
|
||||
const [activeSug, setActiveSug] = useState<Suggestion | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isDanger = (cmd: string) =>
|
||||
DANGEROUS.some(d => cmd.trim().toLowerCase().startsWith(d));
|
||||
|
||||
const fetchSuggestions = useCallback(async (q: string) => {
|
||||
if (!q.trim() || !serverUuid) { setSugs([]); return; }
|
||||
try {
|
||||
const { data } = await http.get(
|
||||
`/api/client/servers/${serverUuid}/addons/console/autocomplete`,
|
||||
{ params: { q } }
|
||||
);
|
||||
setSugs(data.data ?? []);
|
||||
setSelected(-1);
|
||||
} catch {
|
||||
setSugs([]);
|
||||
}
|
||||
}, [serverUuid]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
setValue(v);
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => fetchSuggestions(v), 150);
|
||||
|
||||
// Update active suggestion for syntax helper
|
||||
const match = suggestions.find(s => s.command === v.split(' ')[0]);
|
||||
setActiveSug(match ?? null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, suggestions.length - 1)); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, -1)); }
|
||||
if (e.key === 'Tab' && suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
const idx = selected >= 0 ? selected : 0;
|
||||
applySelection(suggestions[idx]);
|
||||
}
|
||||
if (e.key === 'Escape') { setSugs([]); setSelected(-1); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); submit(); }
|
||||
};
|
||||
|
||||
const applySelection = (sug: Suggestion) => {
|
||||
setValue(sug.command + ' ');
|
||||
setSugs([]);
|
||||
setSelected(-1);
|
||||
setActiveSug(sug);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const cmd = value.trim();
|
||||
if (!cmd) return;
|
||||
if (isDanger(cmd)) {
|
||||
const sug = suggestions.find(s => cmd.startsWith(s.command)) ?? {
|
||||
command: cmd, description: '', syntax: '', dangerous: true, danger_reason: 'Potentially destructive command.', plugin: '', examples: [],
|
||||
};
|
||||
setDanger(sug);
|
||||
return;
|
||||
}
|
||||
send(cmd);
|
||||
};
|
||||
|
||||
const send = (cmd: string) => {
|
||||
onSend(cmd);
|
||||
setValue('');
|
||||
setSugs([]);
|
||||
setActiveSug(null);
|
||||
setDanger(null);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearTimeout(debounceRef.current), []);
|
||||
|
||||
return (
|
||||
<div className='addon-console-input-wrap'>
|
||||
<div className='addon-console-input-row'>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
className='addon-console-input'
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Enter command…'
|
||||
disabled={disabled}
|
||||
autoComplete='off'
|
||||
spellCheck={false}
|
||||
aria-label='Console command input'
|
||||
aria-autocomplete='list'
|
||||
/>
|
||||
<button
|
||||
className='addon-console-send'
|
||||
onClick={submit}
|
||||
disabled={disabled || !value.trim()}
|
||||
aria-label='Send command'
|
||||
>▶</button>
|
||||
</div>
|
||||
|
||||
{/* Syntax helper */}
|
||||
{activeSug && <SyntaxHelper suggestion={activeSug} />}
|
||||
|
||||
{/* Dropdown */}
|
||||
{suggestions.length > 0 && (
|
||||
<AutocompleteDropdown
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selected}
|
||||
onSelect={applySelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Danger modal */}
|
||||
{showDanger && (
|
||||
<DangerCommandWarning
|
||||
suggestion={showDanger}
|
||||
onConfirm={() => send(value.trim())}
|
||||
onCancel={() => { setDanger(null); inputRef.current?.focus(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Danger Command Warning Modal
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Suggestion { command: string; danger_reason?: string; }
|
||||
interface Props { suggestion: Suggestion; onConfirm: () => void; onCancel: () => void; }
|
||||
|
||||
export default function DangerCommandWarning({ suggestion, onConfirm, onCancel }: Props) {
|
||||
return (
|
||||
<div className='addon-modal-overlay' role='dialog' aria-modal='true' aria-label='Danger warning'>
|
||||
<div className='addon-modal addon-modal-danger'>
|
||||
<div className='addon-modal-icon'>⚠️</div>
|
||||
<h3 className='addon-modal-title'>Potentially Dangerous Command</h3>
|
||||
<p className='addon-modal-body'>
|
||||
<code>/{suggestion.command}</code> may have destructive effects.
|
||||
{suggestion.danger_reason && <><br />{suggestion.danger_reason}</>}
|
||||
</p>
|
||||
<div className='addon-modal-actions'>
|
||||
<button className='addon-btn addon-btn-secondary' onClick={onCancel}>Cancel</button>
|
||||
<button className='addon-btn addon-btn-danger' onClick={onConfirm}>Send Anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Syntax Helper — shows command signature below the input
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Suggestion { syntax: string; examples: string[]; plugin: string; }
|
||||
interface Props { suggestion: Suggestion; }
|
||||
|
||||
export default function SyntaxHelper({ suggestion }: Props) {
|
||||
return (
|
||||
<div className='addon-syntax-helper'>
|
||||
<code className='addon-syntax'>{suggestion.syntax}</code>
|
||||
{suggestion.examples.length > 0 && (
|
||||
<span className='addon-syntax-example'>e.g. {suggestion.examples[0]}</span>
|
||||
)}
|
||||
{suggestion.plugin && (
|
||||
<span className='addon-syntax-plugin'>[{suggestion.plugin}]</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Diff Viewer — unified diff with syntax coloring
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import http from '@/api/http';
|
||||
|
||||
interface DiffLine {
|
||||
type: 'context' | 'added' | 'removed' | 'header';
|
||||
content: string;
|
||||
line_old?: number;
|
||||
line_new?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
serverUuid: string;
|
||||
revision: { id: number; revision_number: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DiffViewer({ serverUuid, revision, onClose }: Props) {
|
||||
const [lines, setLines] = useState<DiffLine[]>([]);
|
||||
const [binary, setBinary] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
http.get(`/api/client/servers/${serverUuid}/addons/files/revisions/${revision.id}/diff`)
|
||||
.then(({ data }) => {
|
||||
setBinary(data.binary);
|
||||
setLines(data.lines ?? []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [serverUuid, revision.id]);
|
||||
|
||||
return (
|
||||
<div className='addon-modal-overlay' onClick={onClose} role='dialog' aria-modal='true'>
|
||||
<div className='addon-modal addon-modal-wide' onClick={e => e.stopPropagation()}>
|
||||
<div className='addon-modal-header'>
|
||||
<span>Diff — Revision #{revision.revision_number}</span>
|
||||
<button className='addon-modal-close' onClick={onClose} aria-label='Close'>✕</button>
|
||||
</div>
|
||||
{loading && <div className='addon-loading'>Loading diff…</div>}
|
||||
{!loading && binary && <div className='addon-empty'>Binary file — diff not available.</div>}
|
||||
{!loading && !binary && (
|
||||
<pre className='addon-diff'>
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`addon-diff-line addon-diff-${line.type}`}
|
||||
>
|
||||
<span className='addon-diff-lnum'>
|
||||
{line.line_old ?? ' '} {line.line_new ?? ' '}
|
||||
</span>
|
||||
<span className='addon-diff-sign'>
|
||||
{line.type === 'added' ? '+' : line.type === 'removed' ? '−' : ' '}
|
||||
</span>
|
||||
{line.content}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* File History Button — injected into the file editor toolbar
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import RevisionSidebar from './RevisionSidebar';
|
||||
|
||||
interface Props { filePath: string; }
|
||||
|
||||
export default function FileHistoryButton({ filePath }: Props) {
|
||||
const { id: serverUuid } = useParams<{ id: string }>();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='addon-btn addon-btn-sm addon-btn-history'
|
||||
onClick={() => setOpen(true)}
|
||||
title='View file revision history'
|
||||
aria-label='View file revision history'
|
||||
>
|
||||
🕐 History
|
||||
</button>
|
||||
{open && serverUuid && (
|
||||
<RevisionSidebar
|
||||
serverUuid={serverUuid}
|
||||
filePath={filePath}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Restore Modal — confirmation before overwriting current file
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import http from '@/api/http';
|
||||
|
||||
interface Props {
|
||||
serverUuid: string;
|
||||
revision: { id: number; revision_number: number; change_summary: string };
|
||||
onClose: () => void;
|
||||
onRestored: () => void;
|
||||
}
|
||||
|
||||
export default function RestoreModal({ serverUuid, revision, onClose, onRestored }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleRestore = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await http.post(`/api/client/servers/${serverUuid}/addons/files/revisions/${revision.id}/restore`);
|
||||
onRestored();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.errors?.[0]?.detail ?? 'Restore failed.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='addon-modal-overlay' role='dialog' aria-modal='true'>
|
||||
<div className='addon-modal'>
|
||||
<div className='addon-modal-icon'>🔄</div>
|
||||
<h3 className='addon-modal-title'>Restore Revision #{revision.revision_number}?</h3>
|
||||
<p className='addon-modal-body'>
|
||||
This will overwrite the current file with revision #{revision.revision_number}.<br />
|
||||
<em>{revision.change_summary}</em>
|
||||
</p>
|
||||
{error && <p className='addon-error'>{error}</p>}
|
||||
<div className='addon-modal-actions'>
|
||||
<button className='addon-btn addon-btn-secondary' onClick={onClose} disabled={loading}>Cancel</button>
|
||||
<button className='addon-btn addon-btn-primary' onClick={handleRestore} disabled={loading}>
|
||||
{loading ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Revision Sidebar — slide-in panel listing file revisions
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import http from '@/api/http';
|
||||
import DiffViewer from './DiffViewer';
|
||||
import RestoreModal from './RestoreModal';
|
||||
|
||||
interface Revision {
|
||||
id: number;
|
||||
revision_number: number;
|
||||
size_bytes: number;
|
||||
change_summary: string;
|
||||
created_at: string;
|
||||
author: { username: string } | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
serverUuid: string;
|
||||
filePath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RevisionSidebar({ serverUuid, filePath, onClose }: Props) {
|
||||
const [revisions, setRevisions] = useState<Revision[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewing, setViewing] = useState<Revision | null>(null);
|
||||
const [restoring, setRestoring] = useState<Revision | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
http.get(`/api/client/servers/${serverUuid}/addons/files/revisions`, {
|
||||
params: { path: filePath },
|
||||
})
|
||||
.then(({ data }) => setRevisions(data.data ?? []))
|
||||
.finally(() => setLoading(false));
|
||||
}, [serverUuid, filePath]);
|
||||
|
||||
return (
|
||||
<div className='addon-sidebar-overlay' onClick={onClose} aria-modal='true' role='dialog'>
|
||||
<div className='addon-sidebar' onClick={e => e.stopPropagation()}>
|
||||
<div className='addon-sidebar-header'>
|
||||
<span>🕐 File History — <code>{filePath}</code></span>
|
||||
<button className='addon-sidebar-close' onClick={onClose} aria-label='Close'>✕</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className='addon-loading'>Loading revisions…</div>}
|
||||
|
||||
{!loading && revisions.length === 0 && (
|
||||
<div className='addon-empty'>No revisions recorded for this file yet.</div>
|
||||
)}
|
||||
|
||||
<ul className='addon-revision-list'>
|
||||
{revisions.map(rev => (
|
||||
<li key={rev.id} className='addon-revision-item'>
|
||||
<div className='addon-revision-meta'>
|
||||
<span className='addon-revision-num'>#{rev.revision_number}</span>
|
||||
<span className='addon-revision-author'>{rev.author?.username ?? 'Unknown'}</span>
|
||||
<span className='addon-revision-date'>{new Date(rev.created_at).toLocaleString()}</span>
|
||||
<span className='addon-revision-size'>{(rev.size_bytes / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
<p className='addon-revision-summary'>{rev.change_summary}</p>
|
||||
<div className='addon-revision-actions'>
|
||||
<button className='addon-btn addon-btn-sm' onClick={() => setViewing(rev)}>View Diff</button>
|
||||
<button className='addon-btn addon-btn-sm addon-btn-primary' onClick={() => setRestoring(rev)}>Restore</button>
|
||||
<a
|
||||
className='addon-btn addon-btn-sm'
|
||||
href={`/api/client/servers/${serverUuid}/addons/files/revisions/${rev.id}/download`}
|
||||
download
|
||||
>Download</a>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{viewing && (
|
||||
<DiffViewer
|
||||
serverUuid={serverUuid}
|
||||
revision={viewing}
|
||||
onClose={() => setViewing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{restoring && (
|
||||
<RestoreModal
|
||||
serverUuid={serverUuid}
|
||||
revision={restoring}
|
||||
onClose={() => setRestoring(null)}
|
||||
onRestored={() => { setRestoring(null); onClose(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user