/* * client.js — Eaglercraft Load-Testing Dashboard Frontend * Handles tabs, Socket.IO, bot list, proxy management, logs, inspector. */ 'use strict'; const socket = io(); const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); const tabButtons = $$('.tab-btn'); const tabPanels = $$('.tab-panel'); const targetUrlInput = $('#target-url'); const botCountInput = $('#bot-count'); const botCountDisplay = $('#bot-count-display'); const spawnDelayInput = $('#spawn-delay'); const spawnDelayDisplay = $('#spawn-delay-display'); const usernamePrefixInput = $('#username-prefix'); const botPasswordInput = $('#bot-password'); const chatMessagesInput = $('#chat-messages'); const stayConnectedInput = $('#stay-connected'); const proxyStrategyInput = $('#proxy-strategy'); const btnStartTest = $('#btn-start-test'); const btnStopTest = $('#btn-stop-test'); const btnReset = $('#btn-reset'); const botsTableBody = $('#bots-table-body'); const activeBotCountBadge = $('#active-bot-count-badge'); const proxyInput = $('#proxy-input'); const btnValidateProxies = $('#btn-validate-proxies'); const btnClearDead = $('#btn-clear-dead'); const btnRetestAll = $('#btn-retest-all'); const btnRetestTarget = $('#btn-retest-target'); const proxyImportResult = $('#proxy-import-result'); const proxyTableBody = $('#proxy-table-body'); const proxyCountBadge = $('#proxy-count-badge'); const proxyStatsBadge = $('#proxy-stats-badge'); const proxySearch = $('#proxy-search'); const proxyStatusFilter = $('#proxy-status-filter'); const proxySort = $('#proxy-sort'); const logConsole = $('#log-console'); const btnClearLogs = $('#btn-clear-logs'); const autoScrollLogs = $('#auto-scroll-logs'); const logSearch = $('#log-search'); const filterInfo = $('#filter-info'); const filterWarn = $('#filter-warn'); const filterError = $('#filter-error'); const filterSuccess = $('#filter-success'); const statActiveBots = $('#stat-active-bots'); const statTotalSpawned = $('#stat-total-spawned'); const statHandshakesOk = $('#stat-handshakes-ok'); const statFailed = $('#stat-failed'); const statKeepalives = $('#stat-keepalives'); const statProxiesAlive = $('#stat-proxies-alive'); const statProxiesDead = $('#stat-proxies-dead'); const statAvgScore = $('#stat-avg-score'); const stat2ActiveBots = $('#stat2-active-bots'); const stat2HandshakesOk = $('#stat2-handshakes-ok'); const stat2Failed = $('#stat2-failed'); const stat2Keepalives = $('#stat2-keepalives'); const stat2ProxiesAlive = $('#stat2-proxies-alive'); const stat2ProxiesDead = $('#stat2-proxies-dead'); const stat2AvgScore = $('#stat2-avg-score'); const stat2P95Latency = $('#stat2-p95-latency'); const stat2Target = $('#stat2-target'); const stat2Status = $('#stat2-status'); const stat2Memory = $('#stat2-memory'); const stat2Uptime = $('#stat2-uptime'); const statusIndicator = $('#status-indicator'); const statusText = $('#status-text'); const uptimeText = $('#uptime-text'); let currentBots = []; let currentProxies = []; let proxyJobActive = false; let lastLogEntry = null; tabButtons.forEach(btn => { btn.addEventListener('click', () => { const tabId = btn.dataset.tab; tabButtons.forEach(b => b.classList.remove('active')); tabPanels.forEach(p => p.classList.remove('active')); btn.classList.add('active'); $(`#tab-${tabId}`).classList.add('active'); }); }); botCountInput.addEventListener('input', () => { botCountDisplay.textContent = botCountInput.value; }); spawnDelayInput.addEventListener('input', () => { spawnDelayDisplay.textContent = spawnDelayInput.value; }); function escapeHtml(str) { if (str === null || str === undefined) return ''; const div = document.createElement('div'); div.textContent = String(str); return div.innerHTML; } function formatDuration(ms) { if (!ms || ms < 1000) return '<1s'; const seconds = Math.floor(ms / 1000) % 60; const minutes = Math.floor(ms / 60000) % 60; const hours = Math.floor(ms / 3600000); if (hours > 0) return `${hours}h ${minutes}m`; if (minutes > 0) return `${minutes}m ${seconds}s`; return `${seconds}s`; } function formatUptime(seconds) { if (seconds < 60) return `${seconds}s`; const m = Math.floor(seconds / 60) % 60; const h = Math.floor(seconds / 3600); if (h > 0) return `${h}h ${m}m`; return `${m}m`; } function formatBytes(b) { if (b < 1024) return `${b}B`; if (b < 1024 * 1024) return `${Math.round(b / 1024)}KB`; return `${Math.round(b / 1024 / 1024)}MB`; } function scoreClass(score) { if (score >= 80) return 'score-high'; if (score >= 50) return 'score-mid'; if (score > 0) return 'score-low'; return 'score-zero'; } btnStartTest.addEventListener('click', async () => { const target = targetUrlInput.value.trim(); if (!target) { alert('Please enter a target WebSocket URL.'); return; } if (!target.startsWith('ws://') && !target.startsWith('wss://')) { alert('Target must start with ws:// or wss://'); return; } btnStartTest.disabled = true; const body = { target, bots: parseInt(botCountInput.value, 10), usernamePrefix: usernamePrefixInput.value.trim() || 'TestBot', spawnDelay: parseInt(spawnDelayInput.value, 10), botPassword: botPasswordInput.value.trim() || 'password123', chatMessages: chatMessagesInput.value, stayConnected: stayConnectedInput.checked, proxyStrategy: proxyStrategyInput.value, }; try { const res = await fetch('/api/test/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { if (res.status === 429) { alert('Rate limit hit. Wait a moment before starting another test.'); } else if (res.status === 409) { const doReset = confirm(data.error + '\n\nForce-reset the stuck state?'); if (doReset) { await fetch('/api/test/reset', { method: 'POST' }); alert('State reset. Try starting the test again.'); } } else { alert(data.error || 'Failed to start test.'); } } } catch (err) { alert('Network error: ' + err.message); } finally { btnStartTest.disabled = false; } }); btnStopTest.addEventListener('click', async () => { btnStopTest.disabled = true; try { await fetch('/api/test/stop', { method: 'POST' }); } catch (err) { alert('Network error: ' + err.message); } finally { btnStopTest.disabled = false; } }); btnReset.addEventListener('click', async () => { try { await fetch('/api/test/stop', { method: 'POST' }); await fetch('/api/test/reset', { method: 'POST' }); } catch (err) { alert('Network error: ' + err.message); } }); function renderBotList(bots) { currentBots = bots; activeBotCountBadge.textContent = bots.length; if (bots.length === 0) { botsTableBody.innerHTML = 'No bots running'; return; } const now = Date.now(); botsTableBody.innerHTML = bots.map(bot => { const uptime = bot.connectedAt ? formatDuration(now - bot.connectedAt) : '—'; const stateClass = `badge-${(bot.state || 'unknown').toLowerCase()}`; return ` ${escapeHtml(bot.id)} ${escapeHtml(bot.username)} ${escapeHtml(bot.proxy || 'direct')} ${escapeHtml(bot.state)} ${bot.packetsSent} ${uptime} `; }).join(''); } async function removeBot(botId) { try { await fetch(`/api/bots/${botId}/remove`, { method: 'POST' }); } catch (err) { console.error('Failed to remove bot:', err); } } window.removeBot = removeBot; async function inspectBot(botId) { try { const res = await fetch(`/api/bots/${botId}`); if (!res.ok) { alert('Bot not found or no longer running.'); return; } const data = await res.json(); const bot = data.bot; const inspector = $('#bot-inspector'); const overlay = $('#bot-inspector-overlay'); const body = $('#bot-inspector-body'); $('#bot-inspector-title').textContent = `Bot ${bot.id} — ${bot.username}`; body.innerHTML = `
${escapeHtml(bot.state)}
${escapeHtml(bot.proxy || 'direct')}
${escapeHtml(bot.proxyKey || '—')}
${bot.packetsSent} / ${bot.packetsReceived}
${bot.connectedAt ? new Date(bot.connectedAt).toLocaleString() : '—'}
${bot.lastPacketAt ? new Date(bot.lastPacketAt).toLocaleString() : '—'}
${bot.lastKeepAliveAt ? new Date(bot.lastKeepAliveAt).toLocaleString() : '—'}
${escapeHtml(bot.lastError || '—')}
`; inspector.classList.add('open'); overlay.classList.add('open'); $('#inspector-chat-send').addEventListener('click', async () => { const msg = $('#inspector-chat').value.trim(); if (!msg) return; try { const r = await fetch(`/api/bots/${bot.id}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: msg }), }); if (r.ok) $('#inspector-chat').value = ''; else { const d = await r.json(); alert(d.error || 'Failed'); } } catch (e) { alert('Network error: ' + e.message); } }); } catch (err) { console.error(err); } } window.inspectBot = inspectBot; function closeInspector() { $('#bot-inspector').classList.remove('open'); $('#bot-inspector-overlay').classList.remove('open'); } $('#btn-close-inspector').addEventListener('click', closeInspector); $('#bot-inspector-overlay').addEventListener('click', closeInspector); btnValidateProxies.addEventListener('click', async () => { const text = proxyInput.value.trim(); if (!text) { alert('Please paste proxy list into the textarea.'); return; } btnValidateProxies.disabled = true; proxyJobActive = true; proxyImportResult.classList.remove('hidden', 'success', 'error'); proxyImportResult.innerHTML = 'Queued for validation…'; try { const res = await fetch('/api/proxy/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ proxies: text }), }); const data = await res.json(); if (res.status === 429) { proxyImportResult.classList.add('error'); proxyImportResult.textContent = 'Rate limit hit. Try again later.'; btnValidateProxies.disabled = false; proxyJobActive = false; return; } if (res.ok && data.success) { proxyInput.value = ''; proxyImportResult.classList.add('success'); proxyImportResult.innerHTML = ` Queued ${data.queued} proxies for validation.
${data.duplicates} duplicates skipped, ${data.invalid} invalid.
Starting validation… `; } else { proxyImportResult.classList.add('error'); proxyImportResult.textContent = data.error || 'Failed to queue proxies.'; btnValidateProxies.disabled = false; proxyJobActive = false; } } catch (err) { proxyImportResult.classList.add('error'); proxyImportResult.textContent = 'Network error: ' + err.message; btnValidateProxies.disabled = false; proxyJobActive = false; } }); socket.on('proxy-progress', (job) => { const progressText = document.getElementById('proxy-progress-text'); if (progressText) { const pct = job.total > 0 ? Math.round((job.processed / job.total) * 100) : 0; progressText.textContent = `Validating: ${job.processed}/${job.total} (${pct}%) — ${job.imported ?? job.alive ?? 0} alive, ${job.dead ?? 0} dead`; } loadProxyList(); }); socket.on('proxy-done', (job) => { btnValidateProxies.disabled = false; proxyJobActive = false; proxyImportResult.classList.remove('hidden'); proxyImportResult.classList.add('success'); const alive = job.imported ?? job.alive ?? 0; proxyImportResult.innerHTML = ` Validation complete
Alive: ${alive} · Dead: ${job.dead ?? 0}
${job.errors && job.errors.length > 0 ? '
Errors (first 20):
' + job.errors.slice(0, 20).map(e => escapeHtml(e)).join('
') : ''} `; loadProxyList(); }); socket.on('proxy:update', () => loadProxyList()); socket.on('proxy:remove', () => loadProxyList()); btnClearDead.addEventListener('click', async () => { try { const res = await fetch('/api/proxy/clear-dead', { method: 'POST' }); const data = await res.json(); loadProxyList(); alert(`Removed ${data.removed} dead proxies.`); } catch (err) { alert('Error: ' + err.message); } }); btnRetestAll.addEventListener('click', async () => { btnRetestAll.disabled = true; try { const res = await fetch('/api/proxy/test-all', { method: 'POST' }); if (!res.ok) { const data = await res.json(); alert(data.error || 'Failed to start re-test.'); } } catch (err) { alert('Error: ' + err.message); } finally { setTimeout(() => { btnRetestAll.disabled = false; }, 5000); } }); btnRetestTarget.addEventListener('click', async () => { btnRetestTarget.disabled = true; try { const res = await fetch('/api/proxy/retest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); if (!res.ok) { const data = await res.json(); alert(data.error || 'Failed to start target retest.'); } } catch (err) { alert('Error: ' + err.message); } finally { setTimeout(() => { btnRetestTarget.disabled = false; }, 5000); } }); async function loadProxyList() { try { const params = new URLSearchParams(); if (proxyStatusFilter.value) params.set('status', proxyStatusFilter.value); if (proxySort.value) params.set('sortBy', proxySort.value); const res = await fetch('/api/proxy/list?' + params.toString()); const data = await res.json(); currentProxies = data.proxies || []; renderProxyList(currentProxies, data.stats); } catch (err) { console.error('Failed to load proxies:', err); } } function renderProxyList(proxies, stats) { const search = proxySearch.value.trim().toLowerCase(); const filtered = search ? proxies.filter(p => p.host.toLowerCase().includes(search) || p.port.toString().includes(search)) : proxies; proxyCountBadge.textContent = proxies.length; if (stats) { proxyStatsBadge.textContent = `avg: ${stats.avgScore || 0} | alive: ${stats.alive} | dead: ${stats.dead}`; stat2AvgScore.textContent = stats.avgScore || 0; stat2P95Latency.textContent = stats.p95Latency ? `${stats.p95Latency}ms` : '—'; } if (filtered.length === 0) { proxyTableBody.innerHTML = 'No proxies loaded'; return; } proxyTableBody.innerHTML = filtered.map((p, i) => { const statusClass = `badge-${p.status}`; const scoreCls = scoreClass(p.score); const tested = p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleTimeString() : '—'; const latency = p.p50LatencyMs ? `${p.p50LatencyMs}ms` : (p.responseTime ? `${p.responseTime}ms` : '—'); return ` ${i + 1} ${escapeHtml(p.host)}:${p.port} ${escapeHtml(p.type.toUpperCase())} ${p.status} ${p.score} ${latency} ${tested} `; }).join(''); } async function retestProxy(key) { try { const res = await fetch(`/api/proxy/retest/${encodeURIComponent(key)}`, { method: 'POST' }); const data = await res.json(); if (res.ok) { loadProxyList(); } else { alert(data.error || 'Failed to retest.'); } } catch (err) { alert('Error: ' + err.message); } } window.retestProxy = retestProxy; async function deleteProxy(key) { try { const res = await fetch(`/api/proxy/${encodeURIComponent(key)}`, { method: 'DELETE' }); loadProxyList(); } catch (err) { console.error(err); } } window.deleteProxy = deleteProxy; proxySearch.addEventListener('input', () => renderProxyList(currentProxies)); proxyStatusFilter.addEventListener('change', loadProxyList); proxySort.addEventListener('change', loadProxyList); socket.on('log', (entry) => appendLog(entry)); socket.on('clearLogs', () => { logConsole.innerHTML = '
Logs cleared.
'; }); btnClearLogs.addEventListener('click', async () => { try { await fetch('/api/logs', { method: 'DELETE' }); logConsole.innerHTML = '
Logs cleared.
'; } catch (err) { console.error(err); } }); function levelEnabled(level) { if (level === 'info') return filterInfo.checked; if (level === 'warn') return filterWarn.checked; if (level === 'error') return filterError.checked; if (level === 'success') return filterSuccess.checked; return true; } function logMatches(entry) { if (!levelEnabled(entry.level)) return false; const q = logSearch.value.trim().toLowerCase(); if (q && !entry.message.toLowerCase().includes(q)) return false; return true; } function appendLog(entry) { lastLogEntry = entry; if (!logMatches(entry)) return; const empty = logConsole.querySelector('.log-empty'); if (empty) empty.remove(); const div = document.createElement('div'); div.className = `log-entry log-${entry.level}`; div.innerHTML = `[${entry.time}] ${escapeHtml(entry.message)}`; logConsole.appendChild(div); while (logConsole.children.length > 500) { logConsole.removeChild(logConsole.firstChild); } if (autoScrollLogs.checked) { logConsole.scrollTop = logConsole.scrollHeight; } } [filterInfo, filterWarn, filterError, filterSuccess].forEach(el => el.addEventListener('change', () => { if (!lastLogEntry) return; const all = Array.from(logConsole.querySelectorAll('.log-entry')); all.forEach(node => { const level = Array.from(node.classList).find(c => c.startsWith('log-'))?.replace('log-', ''); if (!levelEnabled(level)) node.style.display = 'none'; else node.style.display = ''; }); })); logSearch.addEventListener('input', () => { if (!lastLogEntry) return; const all = Array.from(logConsole.querySelectorAll('.log-entry')); all.forEach(node => { const level = Array.from(node.classList).find(c => c.startsWith('log-'))?.replace('log-', ''); const text = node.textContent.toLowerCase(); const q = logSearch.value.trim().toLowerCase(); if ((!levelEnabled(level)) || (q && !text.includes(q))) { node.style.display = 'none'; } else { node.style.display = ''; } }); }); socket.on('stats', (data) => { statActiveBots.textContent = data.activeBots; statTotalSpawned.textContent = data.totalBotsSpawned; statHandshakesOk.textContent = data.totalHandshakesOk || 0; statFailed.textContent = data.totalConnectionsFailed; statKeepalives.textContent = data.totalKeepAlivesSent; statProxiesAlive.textContent = data.activeProxies; statProxiesDead.textContent = data.deadProxies; stat2ActiveBots.textContent = data.activeBots; stat2HandshakesOk.textContent = data.totalHandshakesOk || 0; stat2Failed.textContent = data.totalConnectionsFailed; stat2Keepalives.textContent = data.totalKeepAlivesSent; stat2ProxiesAlive.textContent = data.activeProxies; stat2ProxiesDead.textContent = data.deadProxies; stat2Target.textContent = data.currentTarget || 'None'; stat2Status.textContent = data.testRunning ? 'Running' : 'Idle'; if (data.testRunning) { statusIndicator.className = 'status-dot running'; statusText.textContent = `Running (${data.activeBots} bots)`; } else { statusIndicator.className = 'status-dot idle'; statusText.textContent = 'Idle'; } renderBotList(data.bots || []); renderControlsBotList(data.bots || []); }); socket.on('bot:update', () => { socket.emit('stats_request'); }); async function fetchDiagnostics() { try { const res = await fetch('/api/diagnostics'); if (res.ok) { const d = await res.json(); stat2Memory.textContent = d.memory ? formatBytes(d.memory.rss) : '—'; stat2Uptime.textContent = formatUptime(d.uptime || 0); uptimeText.textContent = `up ${formatUptime(d.uptime || 0)}`; } } catch (_) {} } setInterval(fetchDiagnostics, 5000); const btnBotControls = $('#btn-bot-controls'); const btnCloseControls = $('#btn-close-controls'); const botControlsPanel = $('#bot-controls-panel'); const botControlsOverlay = $('#bot-controls-overlay'); const liveChatInput = $('#live-chat-input'); const btnSendChat = $('#btn-send-chat'); const controlsBotList = $('#controls-bot-list'); const quickCommandsRow = $('#quick-commands-row'); btnBotControls.addEventListener('click', () => { botControlsPanel.classList.add('open'); botControlsOverlay.classList.add('open'); liveChatInput.focus(); }); btnCloseControls.addEventListener('click', () => { botControlsPanel.classList.remove('open'); botControlsOverlay.classList.remove('open'); }); botControlsOverlay.addEventListener('click', () => { botControlsPanel.classList.remove('open'); botControlsOverlay.classList.remove('open'); }); async function sendChatToAll(message) { if (!message.trim()) return; try { const res = await fetch('/api/bots/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message.trim() }), }); const data = await res.json(); if (res.status === 429) { alert('Rate limit hit. Slow down.'); } else if (!res.ok) { alert(data.error || 'Failed to send'); } else { liveChatInput.value = ''; } } catch (err) { alert('Network error: ' + err.message); } } btnSendChat.addEventListener('click', () => sendChatToAll(liveChatInput.value)); liveChatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChatToAll(liveChatInput.value); }); function renderControlsBotList(bots) { if (bots.length === 0) { controlsBotList.innerHTML = '
No bots connected
'; return; } controlsBotList.innerHTML = bots.map(b => { const stateClass = `badge-${(b.state || 'unknown').toLowerCase()}`; return `
${escapeHtml(b.username)} ${escapeHtml(b.state)}
`; }).join(''); } fetch('/api/quick-commands') .then(r => r.json()) .then(data => { const cmds = data.commands || []; if (cmds.length > 0) { quickCommandsRow.innerHTML = cmds.map(cmd => `` ).join(''); quickCommandsRow.querySelectorAll('.btn-quick-cmd').forEach(btn => { btn.addEventListener('click', () => sendChatToAll(btn.dataset.cmd)); }); } }) .catch(() => {}); fetch('/api/test/strategies') .then(r => r.json()) .then(data => { if (data.default && proxyStrategyInput) { proxyStrategyInput.value = data.default; } }) .catch(() => {}); fetch('/api/logs') .then(r => r.json()) .then(data => { if (data.logs) { data.logs.forEach(entry => appendLog(entry)); } }) .catch(() => {}); loadProxyList(); fetchDiagnostics();