mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-06-10 09:10:23 +00:00
1267 lines
50 KiB
JavaScript
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();
|
|
}); |