Files
UnHided/mediaflow_proxy/static/speedtest.js
T
UrloMythus bd208c63ff new version
2026-05-19 20:28:26 +02:00

1267 lines
50 KiB
JavaScript

// Speed test functionality
class MediaFlowSpeedTest {
constructor() {
this.config = null;
this.results = {
proxy: {},
direct: {}
};
this.servers = [];
this.currentTestIndex = 0;
this.totalTests = 0;
this.charts = {};
this.testCancelled = false;
this.selectedCdns = new Set();
this.activeAbortControllers = new Set();
this.initializeEventListeners();
this.initializeForm();
this.setupResizeHandler();
}
initializeEventListeners() {
document.getElementById('configForm').addEventListener('submit', (e) => {
e.preventDefault();
this.startSpeedTest();
});
document.getElementById('provider').addEventListener('change', (e) => {
const apiKeySection = document.getElementById('apiKeySection');
if (e.target.value === 'all_debrid') {
apiKeySection.classList.remove('hidden');
} else {
apiKeySection.classList.add('hidden');
}
// Clear CDN selection when provider changes
this.config = null;
this.selectedCdns.clear();
this.showPlaceholderCdnSelection();
this.updateCdnButtonStates();
});
document.getElementById('addServerBtn').addEventListener('click', () => {
this.addServerInput();
});
document.getElementById('runAgainBtn').addEventListener('click', () => {
this.resetTest();
});
document.getElementById('cancelTestBtn').addEventListener('click', () => {
this.cancelTest();
});
document.getElementById('selectAllCdn').addEventListener('click', () => {
this.selectAllCdns(true);
});
document.getElementById('selectNoneCdn').addEventListener('click', () => {
this.selectAllCdns(false);
});
document.getElementById('refreshCdnBtn').addEventListener('click', async () => {
await this.refreshCdnLocations();
});
// Handle server removal
document.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-server')) {
e.target.closest('.server-input').remove();
this.updateRemoveButtons();
}
});
}
initializeForm() {
// Set current URL as default MediaFlow URL
const currentUrl = new URL(window.location.href);
const baseUrl = `${currentUrl.protocol}//${currentUrl.host}`;
const firstServerUrl = document.querySelector('.server-url');
firstServerUrl.value = baseUrl;
firstServerUrl.placeholder = `${baseUrl} (Current Instance)`;
// The top "MediaFlow API Password" (#currentApiPassword) is the
// source of truth; hydrate from localStorage (shared with the URL
// generator) and persist edits back. Per-server fields are treated
// as optional overrides (see collectServerConfigurations).
const topPw = document.getElementById('currentApiPassword');
const saved = localStorage.getItem('mediaflow_api_password');
if (topPw) {
if (saved && !topPw.value) topPw.value = saved;
topPw.addEventListener('input', () => {
const v = topPw.value.trim();
if (v) localStorage.setItem('mediaflow_api_password', v);
else localStorage.removeItem('mediaflow_api_password');
});
}
// Relabel the per-server field so users realise it's an override
// rather than a second required field.
document.querySelectorAll('.server-password').forEach(el => {
el.placeholder = 'Override password (blank = use main)';
});
// Show placeholder CDN selection initially
this.showPlaceholderCdnSelection();
this.updateCdnButtonStates();
}
showPlaceholderCdnSelection() {
const cdnStatusContainer = document.getElementById('cdnStatusContainer');
const cdnContainer = document.getElementById('cdnSelection');
// Clear status container
cdnStatusContainer.innerHTML = '';
// Show placeholder in main CDN container
cdnContainer.innerHTML = `
<div class="col-span-full text-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800">
<div class="text-4xl mb-4">🌐</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">CDN Locations Not Loaded</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
Configure your debrid provider settings above, then click "🔄 Refresh CDNs" to load available locations.
</p>
<div class="text-sm text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 mx-auto max-w-md">
<strong>Steps:</strong><br>
1. Select your debrid provider<br>
2. Enter API key (if required)<br>
3. Click "🔄 Refresh CDNs"<br>
4. Select desired locations<br>
5. Start speed test
</div>
</div>
`;
}
addServerInput() {
const container = document.getElementById('serversContainer');
const serverDiv = document.createElement('div');
serverDiv.className = 'server-input grid grid-cols-1 md:grid-cols-3 gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg';
serverDiv.innerHTML = `
<input
type="url"
placeholder="MediaFlow URL"
class="server-url w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
required
>
<input
type="text"
placeholder="Server Name (optional)"
class="server-name w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
<div class="flex gap-2">
<input
type="password"
placeholder="Override password (blank = use main)"
class="server-password flex-1 px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
<button
type="button"
class="remove-server px-2 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none text-sm"
>
×
</button>
</div>
`;
container.appendChild(serverDiv);
this.updateRemoveButtons();
}
updateRemoveButtons() {
const serverInputs = document.querySelectorAll('.server-input');
serverInputs.forEach((input, index) => {
const removeBtn = input.querySelector('.remove-server');
if (index === 0) {
removeBtn.classList.add('hidden');
} else {
removeBtn.classList.remove('hidden');
}
});
}
async startSpeedTest() {
try {
this.testCancelled = false;
// Check if CDN configuration is loaded
if (!this.config || !this.config.test_urls) {
alert('Please fetch CDN locations first by clicking "🔄 Refresh CDNs" button.');
return;
}
// Collect server configurations
this.collectServerConfigurations();
// Validate server configurations
if (this.servers.length === 0) {
alert('Please add at least one MediaFlow server to test.');
return;
}
// Validate CDN selections
if (this.selectedCdns.size === 0) {
alert('Please select at least one CDN location to test.');
return;
}
// Validate test options
const testProxy = document.getElementById('testProxy').checked;
const testDirect = document.getElementById('testDirect').checked;
if (!testProxy && !testDirect) {
alert('Please select at least one test option (Proxy or Direct).');
return;
}
// Calculate total tests
this.calculateTotalTests();
this.showTestingView();
await this.runTests();
if (!this.testCancelled) {
this.showResults();
}
} catch (error) {
console.error('Speed test failed:', error);
if (!this.testCancelled) {
alert('Speed test failed: ' + error.message);
this.resetTest();
}
}
}
collectServerConfigurations() {
// Collect server configurations
this.servers = [];
const serverInputs = document.querySelectorAll('.server-input');
// Fall back to the top #currentApiPassword when a server's per-row
// override is blank — saves single-server users from typing the
// password twice.
const mainPassword = (document.getElementById('currentApiPassword')?.value || '').trim();
serverInputs.forEach(input => {
const url = input.querySelector('.server-url').value.trim();
const name = input.querySelector('.server-name').value.trim();
const override = input.querySelector('.server-password').value.trim();
const password = override || mainPassword;
if (url) {
this.servers.push({
url: url,
name: name || new URL(url).host,
api_password: password || null
});
}
});
}
async refreshCdnLocations() {
const refreshBtn = document.getElementById('refreshCdnBtn');
const originalText = refreshBtn.textContent;
try {
// Show loading state
refreshBtn.textContent = '⏳ Loading...';
refreshBtn.disabled = true;
// Show loading in CDN container
const cdnStatusContainer = document.getElementById('cdnStatusContainer');
const cdnContainer = document.getElementById('cdnSelection');
cdnStatusContainer.innerHTML = '';
cdnContainer.innerHTML = `
<div class="col-span-full text-center py-8">
<div class="animate-spin text-4xl mb-4">⏳</div>
<p class="text-gray-600 dark:text-gray-400">Loading CDN locations...</p>
</div>
`;
await this.loadConfiguration();
this.populateCdnSelection();
// Show success message
const successMsg = document.createElement('div');
successMsg.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg z-50';
successMsg.textContent = `✅ Loaded ${Object.keys(this.config.test_urls).length} CDN locations`;
document.body.appendChild(successMsg);
setTimeout(() => {
successMsg.remove();
}, 3000);
} catch (error) {
console.error('Failed to refresh CDN locations:', error);
// Show error message
const errorMsg = document.createElement('div');
errorMsg.className = 'fixed top-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50';
errorMsg.textContent = `❌ Failed: ${error.message}`;
document.body.appendChild(errorMsg);
setTimeout(() => {
errorMsg.remove();
}, 5000);
this.showPlaceholderCdnSelection();
this.updateCdnButtonStates();
} finally {
// Restore button state
refreshBtn.textContent = originalText;
refreshBtn.disabled = false;
}
}
cancelTest() {
this.testCancelled = true;
document.getElementById('currentTest').textContent = 'Test cancelled by user';
document.getElementById('progressText').textContent = 'Cancelling...';
// Cancel all active network requests
this.activeAbortControllers.forEach(controller => {
try {
controller.abort();
} catch (e) {
console.warn('Error aborting request:', e);
}
});
this.activeAbortControllers.clear();
setTimeout(() => {
this.resetTest();
}, 1000);
}
populateCdnSelection() {
const cdnStatusContainer = document.getElementById('cdnStatusContainer');
const cdnContainer = document.getElementById('cdnSelection');
const locations = Object.keys(this.config.test_urls);
// Initialize all CDNs as selected if none are selected
if (this.selectedCdns.size === 0) {
locations.forEach(location => this.selectedCdns.add(location));
}
// Show success status
cdnStatusContainer.innerHTML = `
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div class="flex items-center space-x-2 text-green-700 dark:text-green-300">
<span class="text-lg">✅</span>
<span class="font-semibold">CDN Locations Loaded Successfully</span>
<span class="text-sm bg-green-200 dark:bg-green-800 px-2 py-1 rounded">${locations.length} locations</span>
</div>
</div>
`;
// Populate CDN checkboxes in the grid
cdnContainer.innerHTML = locations.map(location => `
<div class="flex items-center space-x-2 p-3 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<input
type="checkbox"
id="cdn-${location}"
class="cdn-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
${this.selectedCdns.has(location) ? 'checked' : ''}
data-location="${location}"
>
<label for="cdn-${location}" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer flex-1">
${location}
</label>
</div>
`).join('');
// Add event listeners to checkboxes
document.querySelectorAll('.cdn-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const location = e.target.dataset.location;
this.toggleCdn(location);
});
});
// Update button states
this.updateCdnButtonStates();
}
toggleCdn(location) {
if (this.selectedCdns.has(location)) {
this.selectedCdns.delete(location);
} else {
this.selectedCdns.add(location);
}
}
selectAllCdns(selectAll) {
if (!this.config || !this.config.test_urls) {
// Show a brief message if CDNs aren't loaded
const message = document.createElement('div');
message.className = 'fixed top-4 right-4 bg-yellow-500 text-white px-4 py-2 rounded-lg shadow-lg z-50';
message.textContent = '⚠️ Please load CDN locations first';
document.body.appendChild(message);
setTimeout(() => message.remove(), 2000);
return;
}
const checkboxes = document.querySelectorAll('.cdn-checkbox');
const locations = Object.keys(this.config.test_urls);
if (selectAll) {
this.selectedCdns = new Set(locations);
checkboxes.forEach(cb => cb.checked = true);
} else {
this.selectedCdns.clear();
checkboxes.forEach(cb => cb.checked = false);
}
}
updateCdnButtonStates() {
const selectAllBtn = document.getElementById('selectAllCdn');
const selectNoneBtn = document.getElementById('selectNoneCdn');
const hasConfig = this.config && this.config.test_urls;
if (hasConfig) {
selectAllBtn.disabled = false;
selectNoneBtn.disabled = false;
selectAllBtn.classList.remove('opacity-50', 'cursor-not-allowed');
selectNoneBtn.classList.remove('opacity-50', 'cursor-not-allowed');
} else {
selectAllBtn.disabled = true;
selectNoneBtn.disabled = true;
selectAllBtn.classList.add('opacity-50', 'cursor-not-allowed');
selectNoneBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
}
async loadConfiguration() {
const provider = document.getElementById('provider').value;
const apiKey = document.getElementById('apiKey').value;
const currentApiPassword = document.getElementById('currentApiPassword').value;
// Use current MediaFlow instance for fetching CDN configuration
const currentUrl = new URL(window.location.href);
const baseUrl = `${currentUrl.protocol}//${currentUrl.host}`;
const requestBody = {
provider: provider,
api_key: apiKey || null,
current_api_password: currentApiPassword || null
};
const headers = {
'Content-Type': 'application/json',
};
// Build URL with api_password as query parameter if provided
// This is more reliable than headers when behind reverse proxies
let configUrl = '/speedtest/config';
if (currentApiPassword) {
configUrl += `?api_password=${encodeURIComponent(currentApiPassword)}`;
}
const response = await fetch(configUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load configuration');
}
this.config = await response.json();
}
calculateTotalTests() {
// Calculate total tests based on selected CDNs and servers
const testProxy = document.getElementById('testProxy').checked;
const testDirect = document.getElementById('testDirect').checked;
const selectedLocationCount = this.selectedCdns.size;
const serverCount = this.servers.length;
this.totalTests = 0;
if (testProxy) this.totalTests += selectedLocationCount * serverCount;
if (testDirect) this.totalTests += selectedLocationCount;
}
showTestingView() {
document.getElementById('configView').classList.add('hidden');
document.getElementById('testingView').classList.remove('hidden');
document.getElementById('resultsView').classList.add('hidden');
// Clear previous results and any existing abort controllers
this.results = {proxy: {}, direct: {}};
this.activeAbortControllers.forEach(controller => {
try {
controller.abort();
} catch (e) {
console.warn('Error aborting request:', e);
}
});
this.activeAbortControllers.clear();
document.getElementById('liveResults').innerHTML = '';
}
showResults() {
document.getElementById('configView').classList.add('hidden');
document.getElementById('testingView').classList.add('hidden');
document.getElementById('resultsView').classList.remove('hidden');
this.renderMetrics();
this.renderCharts();
this.renderDetailedResults();
}
resetTest() {
this.results = {proxy: {}, direct: {}};
this.currentTestIndex = 0;
this.totalTests = 0;
this.testCancelled = false;
// Cancel and clear any remaining abort controllers
this.activeAbortControllers.forEach(controller => {
try {
controller.abort();
} catch (e) {
console.warn('Error aborting request:', e);
}
});
this.activeAbortControllers.clear();
// Destroy existing charts safely
Object.values(this.charts).forEach(chart => {
if (chart && typeof chart.destroy === 'function') {
try {
chart.destroy();
} catch (e) {
console.warn('Error destroying chart:', e);
}
}
});
this.charts = {};
// Clear canvas elements
['speedChart', 'serverChart'].forEach(id => {
const canvas = document.getElementById(id);
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
});
// Clear live results
document.getElementById('liveResults').innerHTML = '';
document.getElementById('configView').classList.remove('hidden');
document.getElementById('testingView').classList.add('hidden');
document.getElementById('resultsView').classList.add('hidden');
}
async runTests() {
const testProxy = document.getElementById('testProxy').checked;
const testDirect = document.getElementById('testDirect').checked;
const testDuration = parseInt(document.getElementById('testDuration').value) || 10;
this.currentTestIndex = 0;
// Validate selected CDNs
if (this.selectedCdns.size === 0) {
throw new Error('Please select at least one CDN location to test');
}
// Filter test URLs to only selected CDNs
const selectedTestUrls = Object.fromEntries(
Object.entries(this.config.test_urls).filter(([location]) =>
this.selectedCdns.has(location)
)
);
// Run proxy tests for each server
if (testProxy && !this.testCancelled) {
for (const server of this.servers) {
if (this.testCancelled) break;
for (const [location, url] of Object.entries(selectedTestUrls)) {
if (this.testCancelled) break;
await this.runSingleTest(location, url, 'proxy', server, testDuration);
}
}
}
// Run direct tests
if (testDirect && !this.testCancelled) {
for (const [location, url] of Object.entries(selectedTestUrls)) {
if (this.testCancelled) break;
await this.runSingleTest(location, url, 'direct', null, testDuration);
}
}
}
async runSingleTest(location, url, testType, server, duration) {
if (this.testCancelled) return;
let testUrl;
let testKey = location;
if (testType === 'proxy') {
testUrl = `${server.url.replace(/\/$/, '')}/proxy/stream?d=${encodeURIComponent(url)}`;
if (server.api_password) {
testUrl += `&api_password=${encodeURIComponent(server.api_password)}`;
}
testKey = `${location}_${server.name}`;
} else {
testUrl = url;
}
this.updateProgress(location, testType, server);
try {
const result = await this.measureSpeed(testUrl, duration);
if (this.testCancelled) return;
this.results[testType][testKey] = {
...result,
server_url: url,
test_url: testUrl,
server_name: server ? server.name : 'Direct',
location: location
};
this.updateLiveResults(testKey, testType, this.results[testType][testKey]);
} catch (error) {
if (this.testCancelled) return;
// Don't log or update UI for cancelled tests
if (error.message === 'Test cancelled') {
return;
}
console.error(`Test failed for ${location} (${testType}):`, error);
this.results[testType][testKey] = {
error: error.message,
server_url: url,
test_url: testUrl,
server_name: server ? server.name : 'Direct',
location: location
};
this.updateLiveResults(testKey, testType, this.results[testType][testKey]);
}
this.currentTestIndex++;
}
async measureSpeed(url, duration) {
const startTime = performance.now();
let totalBytes = 0;
// Create AbortController for this request
const abortController = new AbortController();
this.activeAbortControllers.add(abortController);
try {
// Check if test was cancelled before starting
if (this.testCancelled) {
throw new Error('Test cancelled');
}
const response = await fetch(url, {
method: 'GET',
headers: {
'Range': 'bytes=0-'
},
signal: abortController.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
while (true) {
// Check for cancellation in the reading loop
if (this.testCancelled) {
reader.cancel();
throw new Error('Test cancelled');
}
const {done, value} = await reader.read();
if (done) break;
totalBytes += value.length;
const currentTime = performance.now();
// Check if duration exceeded
if (currentTime - startTime >= duration * 1000) {
reader.cancel();
break;
}
}
// Final cancellation check before returning results
if (this.testCancelled) {
throw new Error('Test cancelled');
}
const actualDuration = (performance.now() - startTime) / 1000;
const speedMbps = (totalBytes * 8) / (actualDuration * 1_000_000);
return {
speed_mbps: Math.round(speedMbps * 100) / 100,
duration: Math.round(actualDuration * 100) / 100,
data_transferred: totalBytes,
timestamp: new Date().toISOString()
};
} catch (error) {
// If it's an abort error and test was cancelled, don't propagate the error
if (error.name === 'AbortError' && this.testCancelled) {
throw new Error('Test cancelled');
}
throw new Error(`Network error: ${error.message}`);
} finally {
// Clean up the abort controller
this.activeAbortControllers.delete(abortController);
}
}
updateProgress(location, testType, server) {
const progress = this.totalTests > 0 ? (this.currentTestIndex / this.totalTests) * 100 : 0;
const progressPercent = Math.min(Math.round(progress), 100); // Cap at 100%
document.getElementById('progressBar').style.width = `${progressPercent}%`;
document.getElementById('progressText').textContent = `${progressPercent}% complete`;
const serverName = server ? server.name : 'Direct';
document.getElementById('currentTest').textContent = `Testing ${location} via ${serverName} (${testType})...`;
}
updateLiveResults(testKey, testType, result) {
const liveResults = document.getElementById('liveResults');
let card = document.getElementById(`result-${testKey}-${testType}`);
if (!card) {
card = document.createElement('div');
card.id = `result-${testKey}-${testType}`;
card.className = 'result-card bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4';
liveResults.appendChild(card);
}
const speedText = result.error
? `<span class="text-red-500">Error: ${result.error}</span>`
: `<span class="text-green-500 font-bold">${result.speed_mbps} Mbps</span>`;
const badgeColor = testType === 'proxy'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
card.innerHTML = `
<div class="flex justify-between items-center mb-2">
<h3 class="font-semibold text-gray-800 dark:text-white">${result.location}</h3>
<span class="text-xs px-2 py-1 rounded ${badgeColor}">
${result.server_name}
</span>
</div>
<div class="text-2xl font-bold mb-1">
${speedText}
</div>
${!result.error ? `
<div class="text-sm text-gray-600 dark:text-gray-400">
${(result.data_transferred / 1024 / 1024).toFixed(2)} MB in ${result.duration}s
</div>
` : ''}
`;
}
renderMetrics() {
const proxyResults = Object.values(this.results.proxy).filter(r => !r.error);
const directResults = Object.values(this.results.direct).filter(r => !r.error);
// Find best proxy result
const bestProxyResult = proxyResults.length > 0
? proxyResults.reduce((best, current) =>
current.speed_mbps > best.speed_mbps ? current : best)
: null;
// Find best direct result
const bestDirectResult = directResults.length > 0
? directResults.reduce((best, current) =>
current.speed_mbps > best.speed_mbps ? current : best)
: null;
const bestProxySpeed = bestProxyResult ? bestProxyResult.speed_mbps : 0;
const bestDirectSpeed = bestDirectResult ? bestDirectResult.speed_mbps : 0;
// Calculate averages
const avgProxySpeed = proxyResults.length > 0
? proxyResults.reduce((sum, r) => sum + r.speed_mbps, 0) / proxyResults.length
: 0;
// Speed difference based on best speeds (more relevant)
const speedDifference = bestDirectSpeed > 0
? ((bestProxySpeed - bestDirectSpeed) / bestDirectSpeed * 100)
: 0;
// Update metrics
document.getElementById('bestProxySpeed').textContent = `${bestProxySpeed.toFixed(2)} Mbps`;
document.getElementById('bestDirectSpeed').textContent = `${bestDirectSpeed.toFixed(2)} Mbps`;
document.getElementById('avgProxySpeed').textContent = `${avgProxySpeed.toFixed(2)} Mbps`;
document.getElementById('speedDifference').textContent = `${speedDifference >= 0 ? '+' : ''}${speedDifference.toFixed(1)}%`;
// Update additional info
document.getElementById('bestProxyServer').textContent = bestProxyResult
? `${bestProxyResult.server_name} - ${bestProxyResult.location}`
: '--';
document.getElementById('bestDirectLocation').textContent = bestDirectResult
? bestDirectResult.location
: '--';
document.getElementById('proxyTestCount').textContent = `${proxyResults.length} tests`;
// Update metric card colors based on performance
const bestProxyMetric = document.getElementById('bestProxyMetric');
const speedDiffMetric = document.getElementById('speedDiffMetric');
// Reset classes
bestProxyMetric.className = 'metric-card text-white p-4 rounded-lg text-center';
speedDiffMetric.className = 'metric-card text-white p-4 rounded-lg text-center';
if (bestProxySpeed >= bestDirectSpeed * 0.8) {
bestProxyMetric.className += ' success';
} else if (bestProxySpeed >= bestDirectSpeed * 0.5) {
bestProxyMetric.className += ' warning';
}
if (speedDifference >= -20) {
speedDiffMetric.className += ' success';
} else {
speedDiffMetric.className += ' warning';
}
// Render server comparison metrics
this.renderServerMetrics();
}
renderServerMetrics() {
const serverComparisonGrid = document.getElementById('serverComparisonGrid');
const proxyResults = Object.values(this.results.proxy).filter(r => !r.error);
// Group results by server
const serverStats = {};
proxyResults.forEach(result => {
if (!serverStats[result.server_name]) {
serverStats[result.server_name] = [];
}
serverStats[result.server_name].push(result);
});
const serverMetrics = Object.entries(serverStats).map(([serverName, results]) => {
const speeds = results.map(r => r.speed_mbps);
const avgSpeed = speeds.reduce((sum, speed) => sum + speed, 0) / speeds.length;
const bestSpeed = Math.max(...speeds);
const testCount = results.length;
const bestLocation = results.find(r => r.speed_mbps === bestSpeed)?.location || '--';
return {
name: serverName,
avgSpeed,
bestSpeed,
testCount,
bestLocation
};
}).sort((a, b) => b.bestSpeed - a.bestSpeed);
serverComparisonGrid.innerHTML = serverMetrics.map((server, index) => {
const rankClass = index === 0 ? 'border-green-500 bg-green-50 dark:bg-green-900/20' :
index === 1 ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' :
'border-gray-300 dark:border-gray-600';
const rankIcon = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`;
return `
<div class="border-2 ${rankClass} rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<h4 class="font-semibold text-gray-800 dark:text-white">${server.name}</h4>
<span class="text-lg">${rankIcon}</span>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Best Speed:</span>
<span class="font-bold text-green-600 dark:text-green-400">${server.bestSpeed.toFixed(2)} Mbps</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Avg Speed:</span>
<span class="font-medium">${server.avgSpeed.toFixed(2)} Mbps</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Best Location:</span>
<span class="font-medium">${server.bestLocation}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Tests:</span>
<span>${server.testCount}</span>
</div>
</div>
</div>
`;
}).join('');
if (serverMetrics.length === 0) {
serverComparisonGrid.innerHTML = `
<div class="col-span-full text-center text-gray-500 dark:text-gray-400 py-8">
No proxy test results available
</div>
`;
}
}
renderCharts() {
const isDark = html.classList.contains('dark');
const textColor = isDark ? '#e5e7eb' : '#374151';
const gridColor = isDark ? '#374151' : '#e5e7eb';
// Add a small delay to ensure DOM is ready
setTimeout(() => {
// Speed Comparison Chart
this.renderSpeedChart(textColor, gridColor);
// Server Performance Chart
this.renderServerChart(textColor, gridColor);
}, 100);
}
setupResizeHandler() {
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (document.getElementById('resultsView').classList.contains('hidden')) {
return; // Don't resize if results view is not visible
}
this.renderCharts();
}, 250);
});
}
renderSpeedChart(textColor, gridColor) {
const canvas = document.getElementById('speedChart');
const ctx = canvas.getContext('2d');
if (this.charts.speedChart) {
this.charts.speedChart.destroy();
}
// Ensure proper canvas sizing
const container = canvas.parentElement;
const containerRect = container.getBoundingClientRect();
canvas.style.width = '100%';
canvas.style.height = '100%';
const locations = [...new Set([
...Object.values(this.results.proxy).map(r => r.location),
...Object.values(this.results.direct).map(r => r.location)
])].filter(Boolean);
if (locations.length === 0) {
return;
}
// Group proxy results by server and location
const serverNames = [...new Set(Object.values(this.results.proxy).map(r => r.server_name))];
// Create datasets for each server + direct
const datasets = [];
// Color palette for different servers
const colors = [
{bg: 'rgba(59, 130, 246, 0.8)', border: 'rgba(59, 130, 246, 1)'}, // Blue
{bg: 'rgba(16, 185, 129, 0.8)', border: 'rgba(16, 185, 129, 1)'}, // Green
{bg: 'rgba(245, 158, 11, 0.8)', border: 'rgba(245, 158, 11, 1)'}, // Yellow
{bg: 'rgba(239, 68, 68, 0.8)', border: 'rgba(239, 68, 68, 1)'}, // Red
{bg: 'rgba(168, 85, 247, 0.8)', border: 'rgba(168, 85, 247, 1)'}, // Purple
];
// Add datasets for each server
serverNames.forEach((serverName, index) => {
const color = colors[index % colors.length];
const data = locations.map(location => {
const result = Object.values(this.results.proxy).find(r =>
r.location === location && r.server_name === serverName && !r.error
);
return result ? result.speed_mbps : 0;
});
datasets.push({
label: `${serverName} (Proxy)`,
data: data,
backgroundColor: color.bg,
borderColor: color.border,
borderWidth: 1
});
});
// Add direct speed dataset
const directData = locations.map(location => {
const result = this.results.direct[location];
return result && !result.error ? result.speed_mbps : 0;
});
datasets.push({
label: 'Direct Connection',
data: directData,
backgroundColor: 'rgba(107, 114, 128, 0.8)',
borderColor: 'rgba(107, 114, 128, 1)',
borderWidth: 1
});
this.charts.speedChart = new Chart(ctx, {
type: 'bar',
data: {
labels: locations,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
},
plugins: {
legend: {
labels: {
color: textColor,
usePointStyle: true,
padding: 15,
font: {
size: 12
}
}
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Speed (Mbps)',
color: textColor
},
ticks: {
color: textColor,
maxTicksLimit: 8
},
grid: {
color: gridColor
}
},
x: {
ticks: {
color: textColor,
maxRotation: 45
},
grid: {
color: gridColor
}
}
}
}
});
}
renderServerChart(textColor, gridColor) {
const canvas = document.getElementById('serverChart');
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get canvas context');
return;
}
if (this.charts.serverChart) {
this.charts.serverChart.destroy();
}
// Ensure proper canvas sizing
const container = canvas.parentElement;
const containerRect = container.getBoundingClientRect();
canvas.style.width = '100%';
canvas.style.height = '100%';
// Group results by server
const serverStats = {};
Object.values(this.results.proxy).forEach(result => {
if (!result.error && result.server_name) {
if (!serverStats[result.server_name]) {
serverStats[result.server_name] = [];
}
serverStats[result.server_name].push(result.speed_mbps);
}
});
const serverNames = Object.keys(serverStats);
if (serverNames.length === 0) {
// Show a message for no data
ctx.fillStyle = textColor;
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText('No server data available', canvas.width / 2, canvas.height / 2);
return;
}
// Use a bar chart instead of radar for better clarity
const avgSpeeds = serverNames.map(name => {
const speeds = serverStats[name];
return speeds.reduce((sum, speed) => sum + speed, 0) / speeds.length;
});
const maxSpeeds = serverNames.map(name => Math.max(...serverStats[name]));
const minSpeeds = serverNames.map(name => Math.min(...serverStats[name]));
this.charts.serverChart = new Chart(ctx, {
type: 'bar',
data: {
labels: serverNames,
datasets: [
{
label: 'Best Speed',
data: maxSpeeds,
backgroundColor: 'rgba(34, 197, 94, 0.8)',
borderColor: 'rgba(34, 197, 94, 1)',
borderWidth: 1,
order: 1
},
{
label: 'Average Speed',
data: avgSpeeds,
backgroundColor: 'rgba(59, 130, 246, 0.8)',
borderColor: 'rgba(59, 130, 246, 1)',
borderWidth: 1,
order: 2
},
{
label: 'Worst Speed',
data: minSpeeds,
backgroundColor: 'rgba(239, 68, 68, 0.8)',
borderColor: 'rgba(239, 68, 68, 1)',
borderWidth: 1,
order: 3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
},
plugins: {
legend: {
labels: {
color: textColor,
usePointStyle: true,
padding: 15,
font: {
size: 12
}
}
},
tooltip: {
callbacks: {
afterLabel: function (context) {
const serverName = context.label;
const speeds = serverStats[serverName];
return `Tests: ${speeds.length}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Speed (Mbps)',
color: textColor
},
ticks: {
color: textColor,
maxTicksLimit: 8
},
grid: {
color: gridColor
}
},
x: {
ticks: {
color: textColor,
maxRotation: 45
},
grid: {
color: gridColor
}
}
}
}
});
}
renderDetailedResults() {
const detailedResults = document.getElementById('detailedResults');
const locations = [...new Set([
...Object.values(this.results.proxy).map(r => r.location),
...Object.values(this.results.direct).map(r => r.location)
])].filter(Boolean);
detailedResults.innerHTML = locations.map(location => {
const proxyResults = Object.values(this.results.proxy).filter(r => r.location === location);
const directResult = this.results.direct[location];
return `
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="font-semibold text-lg text-gray-800 dark:text-white mb-3">${location}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${proxyResults.map(result => `
<div class="space-y-2">
<div class="flex items-center space-x-2">
<span class="text-xs px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded">${result.server_name}</span>
</div>
${result.error ? `
<div class="text-red-500">Error: ${result.error}</div>
` : `
<div class="text-xl font-bold text-blue-600 dark:text-blue-400">${result.speed_mbps} Mbps</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
${(result.data_transferred / 1024 / 1024).toFixed(2)} MB in ${result.duration}s
</div>
`}
</div>
`).join('')}
${directResult ? `
<div class="space-y-2">
<div class="flex items-center space-x-2">
<span class="text-xs px-2 py-1 bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 rounded">Direct</span>
</div>
${directResult.error ? `
<div class="text-red-500">Error: ${directResult.error}</div>
` : `
<div class="text-xl font-bold text-gray-600 dark:text-gray-400">${directResult.speed_mbps} Mbps</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
${(directResult.data_transferred / 1024 / 1024).toFixed(2)} MB in ${directResult.duration}s
</div>
`}
</div>
` : '<div class="text-gray-400">Direct test not performed</div>'}
</div>
</div>
`;
}).join('');
}
}
// Initialize the speed test when the page loads
let speedTest;
document.addEventListener('DOMContentLoaded', () => {
speedTest = new MediaFlowSpeedTest();
});