Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,49 @@ def create_test(
if ping_ms is not None:
data["ping_ms"] = ping_ms

ten_min_ago = datetime.utcnow() - timedelta(minutes=10)
existing_records = (
db.query(models.TestRecord)
.filter(
models.TestRecord.client_ip == client_ip,
models.TestRecord.timestamp >= ten_min_ago,
)
.all()
)

if existing_records:
values_ping = [r.ping_ms for r in existing_records if r.ping_ms is not None]
if data.get("ping_ms") is not None:
values_ping.append(data["ping_ms"])
values_down = [r.download_mbps for r in existing_records if r.download_mbps is not None]
if data.get("download_mbps") is not None:
values_down.append(data["download_mbps"])
values_up = [r.upload_mbps for r in existing_records if r.upload_mbps is not None]
if data.get("upload_mbps") is not None:
values_up.append(data["upload_mbps"])

for r in existing_records:
db.delete(r)

averaged = {
"client_ip": client_ip,
"location": data.get("location") or existing_records[0].location,
"asn": data.get("asn") or existing_records[0].asn,
"isp": data.get("isp") or existing_records[0].isp,
"ping_ms": sum(values_ping) / len(values_ping) if values_ping else None,
"download_mbps": sum(values_down) / len(values_down) if values_down else None,
"upload_mbps": sum(values_up) / len(values_up) if values_up else None,
"speedtest_type": data.get("speedtest_type") or existing_records[0].speedtest_type,
"mtr_result": data.get("mtr_result") or existing_records[0].mtr_result,
"iperf_result": data.get("iperf_result") or existing_records[0].iperf_result,
"test_target": data.get("test_target") or existing_records[0].test_target,
}
db_record = models.TestRecord(**averaged)
db.add(db_record)
db.commit()
db.refresh(db_record)
return db_record

db_record = models.TestRecord(**data)
db.add(db_record)
db.commit()
Expand Down
24 changes: 24 additions & 0 deletions backend/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,27 @@ def test_recent_tests_are_aggregated_by_ip():
assert abs(rec["download_mbps"] - 25) < 0.01
assert abs(rec["upload_mbps"] - 10) < 0.01


def test_create_test_merges_recent_records():
from backend.database import SessionLocal
from backend.models import TestRecord

db = SessionLocal()
try:
db.query(TestRecord).delete()
db.add(TestRecord(client_ip="testclient", ping_ms=10))
db.commit()
finally:
db.close()

res = client.post("/tests", json={"ping_ms": 20})
assert res.status_code == 200

db = SessionLocal()
try:
records = db.query(TestRecord).filter_by(client_ip="testclient").all()
assert len(records) == 1
assert abs(records[0].ping_ms - 15) < 0.01
finally:
db.close()

153 changes: 135 additions & 18 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,20 @@ function App() {
const [info, setInfo] = useState<TestRecord | null>(null);
const [records, setRecords] = useState<TestRecord[]>([]);
const [recordsMessage, setRecordsMessage] = useState<string | null>(null);
const [pingTarget, setPingTarget] = useState('8.8.8.8');
const [pingOutput, setPingOutput] = useState<string | null>(null);
const [traceOutput, setTraceOutput] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState({
transferred: 0,
size: 0,
});
const [uploadProgress, setUploadProgress] = useState({
transferred: 0,
size: 0,
});
const [speedResult, setSpeedResult] = useState<{ down: number; up: number } | null>(
null,
);
const [speedRunning, setSpeedRunning] = useState(false);

useEffect(() => {
const collect = async () => {
Expand All @@ -46,9 +58,6 @@ function App() {
});
const data = await res.json();
setInfo(data);
if (data.client_ip) {
setPingTarget(data.client_ip);
}
} catch (err) {
console.error('Failed to collect info', err);
}
Expand All @@ -71,11 +80,17 @@ function App() {
setTimeout(loadRecords, 0);
}, []);

const runPing = async () => {
if (!pingTarget) return;
useEffect(() => {
if (info?.client_ip) {
runPing(info.client_ip);
runTraceroute(info.client_ip);
}
}, [info]);

const runPing = async (host: string) => {
setPingOutput('Running...');
try {
const res = await fetch(`/ping?host=${encodeURIComponent(pingTarget)}`);
const res = await fetch(`/ping?host=${encodeURIComponent(host)}&count=10`);
const data = await res.json();
setPingOutput(data.output || data.error || 'No output');
} catch (err) {
Expand All @@ -84,6 +99,79 @@ function App() {
}
};

const runTraceroute = async (host: string) => {
setTraceOutput('Running...');
try {
const res = await fetch(`/traceroute?host=${encodeURIComponent(host)}`);
const data = await res.json();
setTraceOutput(data.output || data.error || 'No output');
} catch (err) {
console.error('Traceroute failed', err);
setTraceOutput('Traceroute failed');
}
};

function formatProgress(p: { transferred: number; size: number }) {
if (!p.size) return '';
const transferredMB = (p.transferred / 1024 / 1024).toFixed(2);
const sizeMB = (p.size / 1024 / 1024).toFixed(0);
const percent = ((p.transferred / p.size) * 100).toFixed(0);
return `${transferredMB}M/${sizeMB}M ${percent}%`;
}

async function downloadWithProgress(size: number) {
setDownloadProgress({ transferred: 0, size });
const res = await fetch(`/speedtest/download?size=${size}`);
const reader = res.body?.getReader();
if (!reader) return 0;
let received = 0;
const start = performance.now();
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
setDownloadProgress({ transferred: received, size });
}
const end = performance.now();
return ((size * 8) / (end - start) / 1000);
}

function uploadWithProgress(size: number) {
setUploadProgress({ transferred: 0, size });
return new Promise<number>((resolve) => {
const xhr = new XMLHttpRequest();
const start = performance.now();
xhr.open('POST', '/speedtest/upload');
xhr.upload.onprogress = (e) => {
setUploadProgress({ transferred: e.loaded, size });
};
xhr.onload = () => {
const end = performance.now();
resolve((size * 8) / (end - start) / 1000);
};
xhr.send(new Uint8Array(size));
});
}

const runSpeedtest = async (downloadSize: number, uploadSize: number) => {
setSpeedRunning(true);
setSpeedResult(null);
const down = await downloadWithProgress(downloadSize);
const up = await uploadWithProgress(uploadSize);
setSpeedResult({ down, up });
await fetch('/tests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
test_target: 'speedtest',
speedtest_type: `${downloadSize / 1024 / 1024}M`,
download_mbps: down,
upload_mbps: up,
}),
});
setSpeedRunning(false);
};

return (
<div className="min-h-screen bg-gradient-to-br from-black via-purple-900 to-indigo-900 text-green-400 flex items-center justify-center p-4">
<div className="w-full max-w-2xl space-y-8">
Expand Down Expand Up @@ -164,24 +252,53 @@ function App() {
</div>

<div className="space-y-2 text-center">
<h2 className="text-xl mb-2">Manual Tests</h2>
<h2 className="text-xl mb-2">Auto Ping Test</h2>
{pingOutput && (
<pre className="whitespace-pre-wrap text-left bg-black bg-opacity-50 p-2 rounded">
{pingOutput}
</pre>
)}
</div>

<div className="space-y-2 text-center">
<h2 className="text-xl mb-2">Traceroute</h2>
{traceOutput && (
<pre className="whitespace-pre-wrap text-left bg-black bg-opacity-50 p-2 rounded">
{traceOutput}
</pre>
)}
</div>

<div className="space-y-2 text-center">
<h2 className="text-xl mb-2">Speed Test</h2>
<div className="flex justify-center space-x-2">
<input
className="px-2 py-1 rounded bg-gray-800 text-green-400"
value={pingTarget}
onChange={(e) => setPingTarget(e.target.value)}
placeholder="Host"
/>
<button
className="px-4 py-1 rounded bg-green-600 text-black"
onClick={runPing}
disabled={speedRunning}
onClick={() => runSpeedtest(100 * 1024 * 1024, 50 * 1024 * 1024)}
>
100M
</button>
<button
className="px-4 py-1 rounded bg-green-600 text-black"
disabled={speedRunning}
onClick={() => runSpeedtest(500 * 1024 * 1024, 200 * 1024 * 1024)}
>
Ping
500M
</button>
<button
className="px-4 py-1 rounded bg-green-600 text-black"
disabled={speedRunning}
onClick={() => runSpeedtest(1024 * 1024 * 1024, 500 * 1024 * 1024)}
>
1G
</button>
</div>
{pingOutput && (
<div>Download Progress: {formatProgress(downloadProgress)}</div>
<div>Upload Progress: {formatProgress(uploadProgress)}</div>
{speedResult && (
<pre className="whitespace-pre-wrap text-left bg-black bg-opacity-50 p-2 rounded">
{pingOutput}
{`Download: ${speedResult.down.toFixed(2)} Mbps\nUpload: ${speedResult.up.toFixed(2)} Mbps`}
</pre>
)}
</div>
Expand Down