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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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