549 lines
19 KiB
HTML
549 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>TestArena | Modern Dashboard</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--primary: #6366f1;
|
|
--primary-glow: rgba(99, 102, 241, 0.5);
|
|
--secondary: #ec4899;
|
|
--accent: #8b5cf6;
|
|
--bg: #0f172a;
|
|
--card-bg: rgba(30, 41, 59, 0.7);
|
|
--text: #f8fafc;
|
|
--text-muted: #94a3b8;
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--danger: #ef4444;
|
|
--glass: rgba(255, 255, 255, 0.05);
|
|
--glass-border: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Outfit', sans-serif;
|
|
background: radial-gradient(circle at top right, #1e1b4b, #0f172a);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
padding: 2rem;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
position: relative;
|
|
}
|
|
|
|
/* Decorative blobs */
|
|
.blob {
|
|
position: absolute;
|
|
width: 300px;
|
|
height: 300px;
|
|
background: var(--primary-glow);
|
|
filter: blur(100px);
|
|
border-radius: 50%;
|
|
z-index: -1;
|
|
animation: move 20s infinite alternate;
|
|
}
|
|
|
|
@keyframes move {
|
|
from {
|
|
transform: translate(-10%, -10%);
|
|
}
|
|
|
|
to {
|
|
transform: translate(20%, 20%);
|
|
}
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 3rem;
|
|
padding: 1.5rem;
|
|
background: var(--glass);
|
|
backdrop-filter: blur(12px);
|
|
border: 1px solid var(--glass-border);
|
|
border-radius: 1.5rem;
|
|
}
|
|
|
|
.logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
font-size: 1.75rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(to right, var(--primary), var(--secondary));
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.nav-links {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.nav-links a {
|
|
color: var(--text);
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
transition: color 0.3s;
|
|
}
|
|
|
|
.nav-links a:hover {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.status-badge {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 1rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: var(--glass);
|
|
border: 1px solid var(--glass-border);
|
|
}
|
|
|
|
.dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--warning);
|
|
box-shadow: 0 0 10px var(--warning);
|
|
}
|
|
|
|
.dot.online {
|
|
background: var(--success);
|
|
box-shadow: 0 0 10px var(--success);
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr;
|
|
gap: 2rem;
|
|
}
|
|
|
|
.card {
|
|
background: var(--card-bg);
|
|
backdrop-filter: blur(16px);
|
|
border: 1px solid var(--glass-border);
|
|
border-radius: 1.5rem;
|
|
padding: 2rem;
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
h2 {
|
|
font-size: 1.25rem;
|
|
margin-bottom: 1.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: separate;
|
|
border-spacing: 0 0.75rem;
|
|
}
|
|
|
|
th {
|
|
text-align: left;
|
|
color: var(--text-muted);
|
|
font-weight: 600;
|
|
padding: 0 1rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
td {
|
|
padding: 1rem;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
}
|
|
|
|
td:first-child {
|
|
border-radius: 1rem 0 0 1rem;
|
|
}
|
|
|
|
td:last-child {
|
|
border-radius: 0 1rem 1rem 0;
|
|
}
|
|
|
|
.status-pill {
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 0.75rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.status-waiting {
|
|
background: rgba(148, 163, 184, 0.1);
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.status-running {
|
|
background: rgba(99, 102, 241, 0.1);
|
|
color: #818cf8;
|
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
|
}
|
|
|
|
.status-finished {
|
|
background: rgba(16, 185, 129, 0.1);
|
|
color: #34d399;
|
|
}
|
|
|
|
.status-aborted {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
color: #f87171;
|
|
}
|
|
|
|
.status-timed-out {
|
|
background: rgba(245, 158, 11, 0.1);
|
|
color: #fbbf24;
|
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
|
}
|
|
|
|
.btn-abort {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
color: #f87171;
|
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
padding: 0.4rem 0.8rem;
|
|
border-radius: 0.75rem;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-abort:hover {
|
|
background: var(--danger);
|
|
color: white;
|
|
}
|
|
|
|
.log-container {
|
|
background: #020617;
|
|
border-radius: 1rem;
|
|
padding: 1.25rem;
|
|
height: 400px;
|
|
overflow-y: auto;
|
|
font-family: 'Fira Code', monospace;
|
|
font-size: 0.8125rem;
|
|
line-height: 1.6;
|
|
border: 1px solid var(--glass-border);
|
|
}
|
|
|
|
.log-entry {
|
|
margin-bottom: 0.5rem;
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.log-time {
|
|
color: var(--primary);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.log-msg {
|
|
color: #cbd5e1;
|
|
}
|
|
|
|
/* Custom Scrollbar */
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--glass-border);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="blob"></div>
|
|
<div class="container">
|
|
<header>
|
|
<div class="logo">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
|
</svg>
|
|
TestArena
|
|
</div>
|
|
<nav class="nav-links">
|
|
<a href="/">Dashboard</a>
|
|
<a href="/results/" target="_blank">Browse Results</a>
|
|
</nav>
|
|
<div id="connection-status" class="status-badge">
|
|
<div class="dot"></div>
|
|
<span>Connecting...</span>
|
|
</div>
|
|
<div id="service-status" style="display: flex; gap: 1rem;">
|
|
<div class="status-badge" title="App Service">
|
|
<div id="app-dot" class="dot"></div>
|
|
<span>App</span>
|
|
</div>
|
|
<div class="status-badge" title="Worker Service">
|
|
<div id="worker-dot" class="dot"></div>
|
|
<span>Worker</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="grid">
|
|
<div class="card">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
|
<h2>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
<line x1="3" y1="9" x2="21" y2="9" />
|
|
<line x1="9" y1="21" x2="9" y2="9" />
|
|
</svg>
|
|
Queue Monitor
|
|
</h2>
|
|
<div style="position: relative; width: 300px;">
|
|
<input type="text" id="search-input" placeholder="Search Queue ID..."
|
|
style="width: 100%; padding: 0.6rem 1rem; border-radius: 0.75rem; background: var(--glass); border: 1px solid var(--glass-border); color: var(--text); font-family: inherit;">
|
|
</div>
|
|
</div>
|
|
<table id="queue-table">
|
|
<thead>
|
|
<tr>
|
|
<th onclick="sortTable(0)" style="cursor: pointer;">Queue ID ↕</th>
|
|
<th onclick="sortTable(1)" style="cursor: pointer;">Environment ↕</th>
|
|
<th onclick="sortTable(2)" style="cursor: pointer;">Status ↕</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
|
|
<div id="tasks-section"
|
|
style="margin-top: 3rem; display: none; border-top: 1px solid var(--glass-border); padding-top: 2rem;">
|
|
<div
|
|
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
|
<h2>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 11l3 3L22 4" />
|
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
|
</svg>
|
|
Tasks for <span id="selected-queue-id"></span>
|
|
</h2>
|
|
<button class="btn-abort"
|
|
style="background: var(--glass); color: var(--text); border-color: var(--glass-border);"
|
|
onclick="hideTasks()">Close</button>
|
|
</div>
|
|
<table id="tasks-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Task ID</th>
|
|
<th>Scenario</th>
|
|
<th>Status</th>
|
|
<th>Result</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
Live System Logs
|
|
</h2>
|
|
<div id="logs" class="log-container">
|
|
<div class="log-entry">
|
|
<span class="log-time">23:34:52</span>
|
|
<span class="log-msg">System initialized. Waiting for connection...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let currentQueues = [];
|
|
let sortDirection = [true, true, true];
|
|
|
|
async function fetchStatus() {
|
|
try {
|
|
const response = await fetch('/api/queues');
|
|
currentQueues = await response.json();
|
|
renderTable();
|
|
|
|
const badge = document.getElementById('connection-status');
|
|
badge.querySelector('.dot').classList.add('online');
|
|
badge.querySelector('span').textContent = 'System Online';
|
|
|
|
fetchServiceStatus();
|
|
} catch (e) {
|
|
const badge = document.getElementById('connection-status');
|
|
badge.querySelector('.dot').classList.remove('online');
|
|
badge.querySelector('span').textContent = 'Connection Lost';
|
|
}
|
|
}
|
|
|
|
async function fetchServiceStatus() {
|
|
try {
|
|
const response = await fetch('/api/system/status');
|
|
const status = await response.json();
|
|
|
|
const appDot = document.getElementById('app-dot');
|
|
const workerDot = document.getElementById('worker-dot');
|
|
|
|
if (status['testarena-app'] === 'online') appDot.classList.add('online');
|
|
else appDot.classList.remove('online');
|
|
|
|
if (status['testarena-worker'] === 'online') workerDot.classList.add('online');
|
|
else workerDot.classList.remove('online');
|
|
} catch (e) { }
|
|
}
|
|
|
|
function renderTable() {
|
|
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
|
const tbody = document.querySelector('#queue-table tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
const filteredQueues = currentQueues.filter(q => q.id.toLowerCase().includes(searchTerm));
|
|
|
|
filteredQueues.forEach(q => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td style="font-weight: 600;">${q.id}</td>
|
|
<td><span style="opacity: 0.8;">${q.environment}</span></td>
|
|
<td><span class="status-pill status-${q.status.toLowerCase().replace(' ', '-')}">${q.status}</span></td>
|
|
<td style="display: flex; gap: 0.5rem;">
|
|
<button class="btn-abort" style="background: rgba(99, 102, 241, 0.1); color: #818cf8; border-color: rgba(99, 102, 241, 0.2);" onclick="viewTasks('${q.id}')">Tasks</button>
|
|
<button class="btn-abort" onclick="abortQueue('${q.id}')">Abort</button>
|
|
<button class="btn-abort" style="background: rgba(239, 68, 68, 0.2); border-color: var(--danger);" onclick="deleteQueue('${q.id}')">Delete</button>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function sortTable(n) {
|
|
sortDirection[n] = !sortDirection[n];
|
|
const keys = ['id', 'environment', 'status'];
|
|
const key = keys[n];
|
|
|
|
currentQueues.sort((a, b) => {
|
|
let valA = a[key].toLowerCase();
|
|
let valB = b[key].toLowerCase();
|
|
if (valA < valB) return sortDirection[n] ? -1 : 1;
|
|
if (valA > valB) return sortDirection[n] ? 1 : -1;
|
|
return 0;
|
|
});
|
|
renderTable();
|
|
}
|
|
|
|
document.getElementById('search-input').addEventListener('input', renderTable);
|
|
|
|
async function abortQueue(id) {
|
|
if (confirm(`Are you sure you want to abort queue ${id}?`)) {
|
|
try {
|
|
await fetch(`/api/abort/${id}`, { method: 'POST' });
|
|
addLog(`Aborted queue: ${id}`, 'danger');
|
|
fetchStatus();
|
|
} catch (e) {
|
|
addLog(`Failed to abort queue: ${id}`, 'danger');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function deleteQueue(id) {
|
|
if (confirm(`Are you sure you want to DELETE queue ${id}? This will remove all files and database records.`)) {
|
|
try {
|
|
await fetch(`/api/delete/${id}`, { method: 'DELETE' });
|
|
addLog(`Deleted queue: ${id}`, 'danger');
|
|
fetchStatus();
|
|
} catch (e) {
|
|
addLog(`Failed to delete queue: ${id}`, 'danger');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function viewTasks(queueId) {
|
|
document.getElementById('tasks-section').style.display = 'block';
|
|
document.getElementById('selected-queue-id').textContent = queueId;
|
|
document.getElementById('tasks-section').scrollIntoView({ behavior: 'smooth' });
|
|
|
|
try {
|
|
const response = await fetch(`/api/queue/${queueId}/tasks`);
|
|
const tasks = await response.json();
|
|
const tbody = document.querySelector('#tasks-table tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
tasks.forEach(t => {
|
|
const tr = document.createElement('tr');
|
|
const resultStr = t.result ? JSON.stringify(t.result).substring(0, 50) + '...' : '-';
|
|
tr.innerHTML = `
|
|
<td>${t.id}</td>
|
|
<td title="${t.scenario_path}">${t.scenario_path.split('/').pop()}</td>
|
|
<td><span class="status-pill status-${t.status.toLowerCase().replace(' ', '-')}">${t.status}</span></td>
|
|
<td><small>${resultStr}</small></td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
} catch (e) {
|
|
addLog(`Failed to fetch tasks for ${queueId}`, 'danger');
|
|
}
|
|
}
|
|
|
|
function hideTasks() {
|
|
document.getElementById('tasks-section').style.display = 'none';
|
|
}
|
|
|
|
function addLog(msg, type = 'info') {
|
|
const logs = document.getElementById('logs');
|
|
const entry = document.createElement('div');
|
|
entry.className = 'log-entry';
|
|
const time = new Date().toLocaleTimeString([], { hour12: false });
|
|
entry.innerHTML = `
|
|
<span class="log-time">${time}</span>
|
|
<span class="log-msg">${msg}</span>
|
|
`;
|
|
logs.appendChild(entry);
|
|
logs.scrollTop = logs.scrollHeight;
|
|
}
|
|
|
|
// Initial fetch and poll
|
|
fetchStatus();
|
|
setInterval(fetchStatus, 3000);
|
|
|
|
// Simulate some system logs
|
|
setTimeout(() => addLog("Database connection established."), 1000);
|
|
setTimeout(() => addLog("Background worker is polling for tasks..."), 2000);
|
|
</script>
|
|
</body>
|
|
|
|
</html> |