initial
This commit is contained in:
@@ -0,0 +1,739 @@
|
||||
/*
|
||||
* 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 = '<tr><td colspan="7" class="empty-row">No bots running</td></tr>';
|
||||
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 `
|
||||
<tr>
|
||||
<td>${escapeHtml(bot.id)}</td>
|
||||
<td>${escapeHtml(bot.username)}</td>
|
||||
<td>${escapeHtml(bot.proxy || 'direct')}</td>
|
||||
<td><span class="badge ${stateClass}">${escapeHtml(bot.state)}</span></td>
|
||||
<td>${bot.packetsSent}</td>
|
||||
<td>${uptime}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-small" onclick="removeBot('${bot.id}')">Remove</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="inspectBot('${bot.id}')">Inspect</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).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 = `
|
||||
<div class="form-group">
|
||||
<label>State</label>
|
||||
<div>${escapeHtml(bot.state)}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Proxy</label>
|
||||
<div>${escapeHtml(bot.proxy || 'direct')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Proxy Key</label>
|
||||
<div>${escapeHtml(bot.proxyKey || '—')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Packets Sent / Received</label>
|
||||
<div>${bot.packetsSent} / ${bot.packetsReceived}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Connected At</label>
|
||||
<div>${bot.connectedAt ? new Date(bot.connectedAt).toLocaleString() : '—'}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Last Packet</label>
|
||||
<div>${bot.lastPacketAt ? new Date(bot.lastPacketAt).toLocaleString() : '—'}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Last Keep-Alive</label>
|
||||
<div>${bot.lastKeepAliveAt ? new Date(bot.lastKeepAliveAt).toLocaleString() : '—'}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Last Error</label>
|
||||
<div style="color: var(--accent-red); word-break: break-all;">${escapeHtml(bot.lastError || '—')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Send Chat From This Bot</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="inspector-chat" placeholder="Type a message..." style="flex:1;" />
|
||||
<button class="btn btn-primary btn-small" id="inspector-chat-send">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn btn-danger btn-small" onclick="removeBot('${bot.id}'); closeInspector();">Remove Bot</button>
|
||||
</div>
|
||||
`;
|
||||
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 = '<strong>Queued for validation…</strong>';
|
||||
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 = `
|
||||
<strong>Queued ${data.queued} proxies</strong> for validation.<br>
|
||||
${data.duplicates} duplicates skipped, ${data.invalid} invalid.<br>
|
||||
<span id="proxy-progress-text">Starting validation…</span>
|
||||
`;
|
||||
} 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 = `
|
||||
<strong>Validation complete</strong><br>
|
||||
Alive: ${alive} · Dead: ${job.dead ?? 0}<br>
|
||||
${job.errors && job.errors.length > 0 ? '<br><strong>Errors (first 20):</strong><br>' + job.errors.slice(0, 20).map(e => escapeHtml(e)).join('<br>') : ''}
|
||||
`;
|
||||
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 = '<tr><td colspan="8" class="empty-row">No proxies loaded</td></tr>';
|
||||
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 `
|
||||
<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${escapeHtml(p.host)}:${p.port}</td>
|
||||
<td>${escapeHtml(p.type.toUpperCase())}</td>
|
||||
<td><span class="badge ${statusClass}">${p.status}</span></td>
|
||||
<td><span class="score-cell ${scoreCls}">${p.score}</span></td>
|
||||
<td>${latency}</td>
|
||||
<td>${tested}</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-small" onclick="retestProxy('${p.key}')">Retest</button>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteProxy('${p.key}')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).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 = '<div class="log-empty">Logs cleared.</div>';
|
||||
});
|
||||
|
||||
btnClearLogs.addEventListener('click', async () => {
|
||||
try {
|
||||
await fetch('/api/logs', { method: 'DELETE' });
|
||||
logConsole.innerHTML = '<div class="log-empty">Logs cleared.</div>';
|
||||
} 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 = `<span class="log-time">[${entry.time}]</span> ${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 = '<div class="empty-row">No bots connected</div>';
|
||||
return;
|
||||
}
|
||||
controlsBotList.innerHTML = bots.map(b => {
|
||||
const stateClass = `badge-${(b.state || 'unknown').toLowerCase()}`;
|
||||
return `<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--border-color);font-size:0.75rem;">
|
||||
<span>${escapeHtml(b.username)}</span>
|
||||
<span class="badge ${stateClass}">${escapeHtml(b.state)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
fetch('/api/quick-commands')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const cmds = data.commands || [];
|
||||
if (cmds.length > 0) {
|
||||
quickCommandsRow.innerHTML = cmds.map(cmd =>
|
||||
`<button class="btn btn-secondary btn-small btn-quick-cmd" data-cmd="${escapeHtml(cmd)}">${escapeHtml(cmd)}</button>`
|
||||
).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();
|
||||
@@ -0,0 +1,351 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Eaglercraft Load-Testing Dashboard</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="warning-banner">
|
||||
<strong>FOR AUTHORIZED LOCAL TESTING ONLY</strong> — You must own the target server or have written permission to test.
|
||||
Unauthorized use is illegal and unethical.
|
||||
</div>
|
||||
|
||||
<header id="header">
|
||||
<div class="header-left">
|
||||
<h1>Eaglercraft Load Tester</h1>
|
||||
<span class="subtitle">MC 1.12.2 (Protocol 340) — Eaglercraft WebSocket</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="status-indicator" class="status-dot idle"></span>
|
||||
<span id="status-text">Idle</span>
|
||||
<span id="uptime-text" class="uptime-text"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav id="tab-nav">
|
||||
<button class="tab-btn active" data-tab="test-control">Test Control</button>
|
||||
<button class="tab-btn" data-tab="proxy-manager">Proxy Manager</button>
|
||||
<button class="tab-btn" data-tab="logs">Connection Logs</button>
|
||||
<button class="tab-btn" data-tab="stats">Server Stats</button>
|
||||
</nav>
|
||||
|
||||
<div id="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Active Bots</span>
|
||||
<span class="stat-value" id="stat-active-bots">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Spawned</span>
|
||||
<span class="stat-value" id="stat-total-spawned">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Handshakes OK</span>
|
||||
<span class="stat-value" id="stat-handshakes-ok">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Failed</span>
|
||||
<span class="stat-value" id="stat-failed">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Keep-Alives Sent</span>
|
||||
<span class="stat-value" id="stat-keepalives">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Proxies (Alive/Dead)</span>
|
||||
<span class="stat-value"><span id="stat-proxies-alive">0</span>/<span id="stat-proxies-dead">0</span></span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Avg Score</span>
|
||||
<span class="stat-value" id="stat-avg-score">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main id="tab-content">
|
||||
|
||||
<section id="tab-test-control" class="tab-panel active">
|
||||
<div class="panel-grid">
|
||||
<div class="panel card">
|
||||
<h2>Launch Load Test</h2>
|
||||
<div class="form-group">
|
||||
<label for="target-url">Target WebSocket URL</label>
|
||||
<input type="text" id="target-url" placeholder="ws://your-server.com:8081" />
|
||||
<span class="input-help">Eaglercraft servers use ws:// or wss:// (e.g., ws://localhost:8081)</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="bot-count">Number of Bots: <span id="bot-count-display">10</span></label>
|
||||
<input type="range" id="bot-count" min="1" max="500" value="10" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="spawn-delay">Spawn Delay (ms between bots): <span id="spawn-delay-display">200</span></label>
|
||||
<input type="range" id="spawn-delay" min="0" max="5000" step="50" value="200" />
|
||||
<span class="input-help">Time to wait between spawning each bot. 0ms = all at once, 200ms = staggered (recommended).</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="stay-connected" />
|
||||
Keep bots connected after messages (don't auto-disconnect)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="username-prefix">Username Prefix</label>
|
||||
<input type="text" id="username-prefix" value="TestBot" placeholder="TestBot" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="bot-password">Bot Password (for /register and /login)</label>
|
||||
<input type="text" id="bot-password" value="password123" placeholder="password123" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="chat-messages">Chat Messages (one per line, sent after login)</label>
|
||||
<textarea id="chat-messages" rows="4" placeholder="Hello! I just joined the server Nice server!"></textarea>
|
||||
<span class="input-help">Each message sent 1.5s apart, starting after auth completes.</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="proxy-strategy">Proxy Strategy</label>
|
||||
<select id="proxy-strategy">
|
||||
<option value="best-score">Best Score (weighted)</option>
|
||||
<option value="random">Random</option>
|
||||
<option value="round-robin">Round Robin</option>
|
||||
<option value="least-used">Least Used</option>
|
||||
</select>
|
||||
<span class="input-help">How bots pick from the proxy pool. Best-score favors healthy proxies.</span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="btn-start-test" class="btn btn-primary">Run Load Test</button>
|
||||
<button id="btn-stop-test" class="btn btn-danger">Stop Test</button>
|
||||
<button id="btn-reset" class="btn btn-secondary" title="Force-reset if state is stuck">Force Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel card">
|
||||
<h2>
|
||||
Active Bots <span class="badge badge-info" id="active-bot-count-badge">0</span>
|
||||
<button id="btn-bot-controls" class="btn btn-secondary btn-small" style="margin-left:12px;">Bot Controls</button>
|
||||
</h2>
|
||||
<div class="table-container" id="bots-table-container">
|
||||
<table id="bots-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Proxy</th>
|
||||
<th>State</th>
|
||||
<th>Packets</th>
|
||||
<th>Uptime</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bots-table-body">
|
||||
<tr><td colspan="7" class="empty-row">No bots running</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-proxy-manager" class="tab-panel">
|
||||
<div class="panel-grid">
|
||||
<div class="panel card">
|
||||
<h2>Upload Proxy List</h2>
|
||||
<p class="help-text">
|
||||
Enter one proxy per line. Supported formats:<br>
|
||||
<code>socks4://host:port</code> ·
|
||||
<code>socks5://host:port</code> ·
|
||||
<code>socks5://user:pass@host:port</code><br>
|
||||
Validation probes 8.8.8.8:53 by default; switch to "probe target" to test against the active test target.<br>
|
||||
<strong>Note:</strong> Bot traffic is routed through alive proxies automatically.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<textarea id="proxy-input" rows="10" placeholder="socks5://1.2.3.4:1080 socks4://5.6.7.8:1080 socks5://user:pass@9.10.11.12:1080"></textarea>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="btn-validate-proxies" class="btn btn-primary">Validate & Import Proxies</button>
|
||||
<button id="btn-clear-dead" class="btn btn-warning">Clear Dead Proxies</button>
|
||||
<button id="btn-retest-all" class="btn btn-secondary">Re-Test All</button>
|
||||
<button id="btn-retest-target" class="btn btn-secondary">Re-Test vs Target</button>
|
||||
</div>
|
||||
<div id="proxy-import-result" class="result-box hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel card">
|
||||
<h2>
|
||||
Proxy Pool <span class="badge badge-info" id="proxy-count-badge">0</span>
|
||||
<span class="badge badge-info" id="proxy-stats-badge">avg: —</span>
|
||||
</h2>
|
||||
<div class="filter-row">
|
||||
<input type="text" id="proxy-search" placeholder="Filter by host…" />
|
||||
<select id="proxy-status-filter">
|
||||
<option value="">All</option>
|
||||
<option value="alive">Alive</option>
|
||||
<option value="dead">Dead</option>
|
||||
<option value="degraded">Degraded</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
<select id="proxy-sort">
|
||||
<option value="added">Sort: Added</option>
|
||||
<option value="score">Sort: Score</option>
|
||||
<option value="latency">Sort: Latency</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-container" id="proxy-table-container">
|
||||
<table id="proxy-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Address</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Score</th>
|
||||
<th>Latency</th>
|
||||
<th>Last Tested</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="proxy-table-body">
|
||||
<tr><td colspan="8" class="empty-row">No proxies loaded</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-logs" class="tab-panel">
|
||||
<div class="panel card full-width">
|
||||
<div class="log-header">
|
||||
<h2>Connection Logs</h2>
|
||||
<div class="button-row">
|
||||
<button id="btn-clear-logs" class="btn btn-warning">Clear Logs</button>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="auto-scroll-logs" checked />
|
||||
Auto-scroll
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="filter-info" checked /> Info
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="filter-warn" checked /> Warn
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="filter-error" checked /> Error
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="filter-success" checked /> Success
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" id="log-search" placeholder="Filter logs by message…" />
|
||||
<div id="log-console" class="log-console">
|
||||
<div class="log-empty">Logs will appear here when a test is running.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-stats" class="tab-panel">
|
||||
<div class="panel-grid stats-grid">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-card-label">Active Connections</div>
|
||||
<div class="stat-card-value" id="stat2-active-bots">0</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-card-label">Successful Logins</div>
|
||||
<div class="stat-card-value" id="stat2-handshakes-ok">0</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-card-label">Connection Failures</div>
|
||||
<div class="stat-card-value" id="stat2-failed">0</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-card-label">Total Keep-Alives</div>
|
||||
<div class="stat-card-value" id="stat2-keepalives">0</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-card-label">Alive Proxies</div>
|
||||
<div class="stat-card-value" id="stat2-proxies-alive">0</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-card-label">Dead Proxies</div>
|
||||
<div class="stat-card-value" id="stat2-proxies-dead">0</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-card-label">Avg Proxy Score</div>
|
||||
<div class="stat-card-value" id="stat2-avg-score">—</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-card-label">P95 Latency</div>
|
||||
<div class="stat-card-value" id="stat2-p95-latency">—</div>
|
||||
</div>
|
||||
<div class="card stat-card wide">
|
||||
<div class="stat-card-label">Target Server</div>
|
||||
<div class="stat-card-value" id="stat2-target">None</div>
|
||||
</div>
|
||||
<div class="card stat-card wide">
|
||||
<div class="stat-card-label">Test Status</div>
|
||||
<div class="stat-card-value" id="stat2-status">Idle</div>
|
||||
</div>
|
||||
<div class="card stat-card wide">
|
||||
<div class="stat-card-label">Memory (RSS)</div>
|
||||
<div class="stat-card-value" id="stat2-memory">—</div>
|
||||
</div>
|
||||
<div class="card stat-card wide">
|
||||
<div class="stat-card-label">Uptime</div>
|
||||
<div class="stat-card-value" id="stat2-uptime">—</div>
|
||||
</div>
|
||||
<div class="card stat-card wide">
|
||||
<div class="stat-card-label">Protocol Info</div>
|
||||
<div class="stat-card-value protocol-info">MC 1.12.2 · Protocol 340 · VarInt-Length-Framed Packets · Compression-Aware</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<div id="bot-controls-panel" class="side-panel">
|
||||
<div class="side-panel-header">
|
||||
<h3>Bot Controls</h3>
|
||||
<button id="btn-close-controls" class="btn btn-secondary btn-small">×</button>
|
||||
</div>
|
||||
<div class="side-panel-body">
|
||||
<div class="form-group">
|
||||
<label>Send Chat to All Bots</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="live-chat-input" placeholder="Type a message..." style="flex:1;" />
|
||||
<button id="btn-send-chat" class="btn btn-primary">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Quick Commands</label>
|
||||
<div class="button-row" id="quick-commands-row" style="flex-direction:column;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Connected Bots</label>
|
||||
<div id="controls-bot-list" class="table-container" style="max-height:300px;">
|
||||
<div class="empty-row">No bots connected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bot-controls-overlay" class="side-panel-overlay"></div>
|
||||
|
||||
<div id="bot-inspector" class="side-panel">
|
||||
<div class="side-panel-header">
|
||||
<h3 id="bot-inspector-title">Bot Inspector</h3>
|
||||
<button id="btn-close-inspector" class="btn btn-secondary btn-small">×</button>
|
||||
</div>
|
||||
<div class="side-panel-body" id="bot-inspector-body">
|
||||
</div>
|
||||
</div>
|
||||
<div id="bot-inspector-overlay" class="side-panel-overlay"></div>
|
||||
|
||||
<footer id="footer">
|
||||
Eaglercraft Load-Testing Dashboard v2.0 — MC 1.12.2 · For authorized local testing only.
|
||||
</footer>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login — Eaglercraft Load Tester</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: #4a1010;
|
||||
border-bottom: 2px solid #f85149;
|
||||
color: #ffa0a0;
|
||||
text-align: center;
|
||||
padding: 10px 16px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.warning strong { color: #f85149; text-transform: uppercase; }
|
||||
|
||||
.login-card {
|
||||
background: #1c2128;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 36px 32px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-card .subtitle {
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
color: #8b949e;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #8b949e;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
background: #1f6feb;
|
||||
border: 1px solid #388bfd;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-login:hover { background: #388bfd; }
|
||||
.btn-login:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.error-msg {
|
||||
color: #f85149;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #161b22;
|
||||
border-top: 1px solid #30363d;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 0.7rem;
|
||||
color: #6e7681;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="warning">
|
||||
<strong>FOR AUTHORIZED LOCAL TESTING ONLY</strong> — You must own the target server or have written permission.
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<h1>Eaglercraft Load Tester</h1>
|
||||
<p class="subtitle">Enter password to access the dashboard</p>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" placeholder="Enter password" autofocus />
|
||||
</div>
|
||||
<button type="submit" class="btn-login" id="btn-login">Unlock</button>
|
||||
<div class="error-msg" id="error-msg"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Eaglercraft Load-Testing Dashboard — MC 1.8 Protocol — Authorized testing only.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// If already authenticated, redirect to dashboard
|
||||
fetch('/api/auth/check')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.authenticated) window.location.href = '/';
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const form = document.getElementById('login-form');
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
const btnLogin = document.getElementById('btn-login');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('password').value;
|
||||
if (!password) return;
|
||||
|
||||
errorMsg.textContent = '';
|
||||
btnLogin.disabled = true;
|
||||
btnLogin.textContent = 'Checking...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
errorMsg.textContent = data.error || 'Invalid password';
|
||||
btnLogin.disabled = false;
|
||||
btnLogin.textContent = 'Unlock';
|
||||
document.getElementById('password').select();
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.textContent = 'Network error';
|
||||
btnLogin.disabled = false;
|
||||
btnLogin.textContent = 'Unlock';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,833 @@
|
||||
/*
|
||||
* style.css — Eaglercraft Load-Testing Dashboard
|
||||
* Dark theme with clean, functional layout.
|
||||
*/
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* RESET & BASE */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--bg-card: #1c2128;
|
||||
--border-color: #30363d;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--accent-blue: #58a6ff;
|
||||
--accent-green: #3fb950;
|
||||
--accent-red: #f85149;
|
||||
--accent-yellow: #d29922;
|
||||
--accent-purple: #bc8cff;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2);
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* WARNING BANNER */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
#warning-banner {
|
||||
background: #4a1010;
|
||||
border-bottom: 2px solid var(--accent-red);
|
||||
color: #ffa0a0;
|
||||
text-align: center;
|
||||
padding: 10px 16px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
#warning-banner strong {
|
||||
color: var(--accent-red);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* HEADER */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
#header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#header h1 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#header .subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.idle { background: var(--text-muted); }
|
||||
.status-dot.running { background: var(--accent-green); animation: pulse 1.5s infinite; }
|
||||
.status-dot.error { background: var(--accent-red); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* TAB NAVIGATION */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
#tab-nav {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 24px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 10px 18px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--accent-blue);
|
||||
border-bottom-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* STATS BAR */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
#stats-bar {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
overflow-x: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* TAB CONTENT */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
#tab-content {
|
||||
flex: 1;
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* CARDS & PANELS */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.panel-grid .card.full-width,
|
||||
.panel-grid.stats-grid .card.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* FORM ELEMENTS */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent-blue);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* BUTTONS */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 18px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1f6feb;
|
||||
border-color: #388bfd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #388bfd;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #4a1010;
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #6e1a1a;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #3d2e00;
|
||||
border-color: var(--accent-yellow);
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: #5a4500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* TABLES */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 20px 10px !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* STATUS BADGES */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.badge-connecting {
|
||||
background: #2d333b;
|
||||
color: var(--accent-yellow);
|
||||
border: 1px solid var(--accent-yellow);
|
||||
}
|
||||
|
||||
.badge-connected {
|
||||
background: #0d2818;
|
||||
color: var(--accent-green);
|
||||
border: 1px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.badge-failed {
|
||||
background: #3d1010;
|
||||
color: var(--accent-red);
|
||||
border: 1px solid var(--accent-red);
|
||||
}
|
||||
|
||||
.badge-stopped {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.badge-alive {
|
||||
background: #0d2818;
|
||||
color: var(--accent-green);
|
||||
border: 1px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.badge-dead {
|
||||
background: #3d1010;
|
||||
color: var(--accent-red);
|
||||
border: 1px solid var(--accent-red);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* LOG CONSOLE */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.log-header h2 {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-console {
|
||||
background: #010409;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid #161b22;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-entry .log-time {
|
||||
color: var(--text-muted);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.log-entry.log-info { color: var(--accent-blue); }
|
||||
.log-entry.log-warn { color: var(--accent-yellow); }
|
||||
.log-entry.log-error { color: var(--accent-red); }
|
||||
.log-entry.log-success { color: var(--accent-green); }
|
||||
|
||||
.log-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* STATS CARDS */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.stat-card-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.stat-card:nth-child(3) .stat-card-value { color: var(--accent-red); }
|
||||
.stat-card:nth-child(4) .stat-card-value { color: var(--accent-green); }
|
||||
.stat-card:nth-child(5) .stat-card-value { color: var(--accent-green); }
|
||||
.stat-card:nth-child(6) .stat-card-value { color: var(--accent-red); }
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* HELP TEXT */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.help-text {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help-text code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.input-help {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #1f3a5f;
|
||||
color: var(--accent-blue);
|
||||
border: 1px solid var(--accent-blue);
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.protocol-info {
|
||||
font-size: 0.9rem !important;
|
||||
color: var(--accent-purple) !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* FILTER ROW */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-row input,
|
||||
.filter-row select {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
#log-search {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.uptime-text {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* SCORE COLORS */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.score-cell {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.score-cell.score-high {
|
||||
background: #0d2818;
|
||||
color: var(--accent-green);
|
||||
border: 1px solid var(--accent-green);
|
||||
}
|
||||
.score-cell.score-mid {
|
||||
background: #3d2e00;
|
||||
color: var(--accent-yellow);
|
||||
border: 1px solid var(--accent-yellow);
|
||||
}
|
||||
.score-cell.score-low {
|
||||
background: #3d1010;
|
||||
color: var(--accent-red);
|
||||
border: 1px solid var(--accent-red);
|
||||
}
|
||||
.score-cell.score-zero {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* ADDITIONAL STATE BADGES (bot states) */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.badge-idle, .badge-connecting, .badge-eagler_handshake, .badge-eagler_login,
|
||||
.badge-mc_play, .badge-authenticating {
|
||||
background: #2d333b;
|
||||
color: var(--accent-yellow);
|
||||
border: 1px solid var(--accent-yellow);
|
||||
}
|
||||
.badge-ready, .badge-chatting, .badge-done, .badge-connected {
|
||||
background: #0d2818;
|
||||
color: var(--accent-green);
|
||||
border: 1px solid var(--accent-green);
|
||||
}
|
||||
.badge-failed, .badge-dead_socket, .badge-stopped {
|
||||
background: #3d1010;
|
||||
color: var(--accent-red);
|
||||
border: 1px solid var(--accent-red);
|
||||
}
|
||||
.badge-degraded, .badge-unknown, .badge-retesting {
|
||||
background: #1f3a5f;
|
||||
color: var(--accent-blue);
|
||||
border: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* RESULT BOX */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.result-box {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result-box.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-box.success {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.result-box.error {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* CHECKBOX */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
accent-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* SIDE PANEL (Bot Controls)
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
.side-panel-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
.side-panel-overlay.open { display: block; }
|
||||
|
||||
.side-panel {
|
||||
position: fixed;
|
||||
top: 0; right: -400px;
|
||||
width: 380px;
|
||||
height: 100vh;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: right 0.25s ease;
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,0.4);
|
||||
}
|
||||
.side-panel.open { right: 0; }
|
||||
|
||||
.side-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.side-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.side-panel-body {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-quick-cmd {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
} */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
#footer {
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* RESPONSIVE */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#stats-bar {
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
#tab-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#header {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#header .subtitle {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
html {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user