fix loading issue

This commit is contained in:
2026-01-04 17:52:51 +01:00
parent ce37351722
commit e33ce220e9
3 changed files with 189 additions and 92 deletions

View File

@@ -4,10 +4,38 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="panel"> <div class="panel sidebar-panel">
<div class="panel-header"> <div class="panel-header">
<h3>Test Jobs</h3> <h3>Test Jobs</h3>
<a href="{{ url_for('jobs.submit') }}" class="btn btn-primary btn-sm">+ New Job</a> <div style="display: flex; gap: 5px;">
<button class="btn btn-secondary btn-sm" onclick="toggleSearch()" title="Search Jobs">🔍</button>
<a href="{{ url_for('jobs.submit') }}" class="btn btn-primary btn-sm">+ New</a>
</div>
</div>
<div id="search-panel" class="search-box"
style="display: {{ 'block' if username_query or job_id_query else 'none' }}; padding: 15px; border-bottom: 1px solid var(--border); background: var(--light);">
<form action="{{ url_for('dashboard.index') }}" method="GET"
style="display: flex; flex-direction: column; gap: 10px;">
<div class="form-group">
<label
style="font-size: 11px; font-weight: 600; color: var(--gray); margin-bottom: 4px; display: block;">JOB
ID (GLOBAL)</label>
<input type="text" name="job_id" placeholder="e.g. 345" value="{{ job_id_query or '' }}"
class="form-control">
</div>
<div class="form-group">
<label
style="font-size: 11px; font-weight: 600; color: var(--gray); margin-bottom: 4px; display: block;">USERNAME
(GLOBAL)</label>
<input type="text" name="username" placeholder="e.g. admin" value="{{ username_query or '' }}"
class="form-control">
</div>
<div style="display: flex; gap: 5px; margin-top: 5px;">
<button type="submit" class="btn btn-primary btn-sm" style="flex: 1;">Search</button>
<a href="{{ url_for('dashboard.index') }}" class="btn btn-secondary btn-sm">Clear</a>
</div>
</form>
</div> </div>
<div class="job-list"> <div class="job-list">
@@ -24,16 +52,20 @@
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<h3>No jobs yet</h3> <h3>No jobs found</h3>
<p>Submit your first test job to get started</p> <p>Try a different search or submit a new job</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="panel"> <div class="panel details-panel">
<div class="panel-header"> <div class="panel-header">
<h3>Job Details</h3> <h3>Job Details</h3>
<div id="job-actions" style="display: none; gap: 5px;">
<button class="btn btn-danger btn-sm" id="btn-abort" onclick="abortJob(activeJobId)">Abort</button>
<button class="btn btn-danger btn-sm" id="btn-delete" onclick="deleteJob(activeJobId)">Delete</button>
</div>
</div> </div>
<div id="job-details-container"> <div id="job-details-container">
@@ -47,31 +79,73 @@
<!-- Context Menu --> <!-- Context Menu -->
<div id="contextMenu" class="context-menu"> <div id="contextMenu" class="context-menu">
<div class="context-menu-item" onclick="abortJobFromContext()"> <div class="context-menu-item" id="ctx-abort" onclick="abortJobFromContext()">
<span class="context-menu-icon"></span> <span class="context-menu-icon"></span>
Abort Job Abort Job
</div> </div>
<div class="context-menu-item" onclick="deleteJobFromContext()">
<span class="context-menu-icon">🗑️</span>
Delete Job
</div> </div>
</div>
<style>
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
margin-top: 20px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border-bottom: 2px solid transparent;
font-weight: 600;
color: var(--gray);
}
.tab.active {
border-bottom-color: var(--primary);
color: var(--primary);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
<script> <script>
let contextJobId = null; let contextJobId = null;
let activeJobId = null;
let pollingInterval = null;
let currentActiveTab = 'scenarios';
function toggleSearch() {
const panel = document.getElementById('search-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
function showContextMenu(event, jobId, status) { function showContextMenu(event, jobId, status) {
event.preventDefault(); event.preventDefault();
// Only show context menu for in_progress jobs
if (status !== 'in_progress') {
return;
}
contextJobId = jobId; contextJobId = jobId;
const contextMenu = document.getElementById('contextMenu'); const contextMenu = document.getElementById('contextMenu');
const abortItem = document.getElementById('ctx-abort');
if (status === 'in_progress' || status === 'waiting') {
abortItem.style.display = 'flex';
} else {
abortItem.style.display = 'none';
}
contextMenu.style.display = 'block'; contextMenu.style.display = 'block';
contextMenu.style.left = event.pageX + 'px'; contextMenu.style.left = event.pageX + 'px';
contextMenu.style.top = event.pageY + 'px'; contextMenu.style.top = event.pageY + 'px';
// Hide context menu when clicking elsewhere
document.addEventListener('click', hideContextMenu); document.addEventListener('click', hideContextMenu);
} }
@@ -82,34 +156,25 @@
} }
function abortJobFromContext() { function abortJobFromContext() {
if (contextJobId) { if (contextJobId) abortJob(contextJobId);
abortJob(contextJobId);
}
hideContextMenu(); hideContextMenu();
} }
let pollingInterval = null;
function loadJobDetails(jobId) { function deleteJobFromContext() {
// Clear existing polling if (contextJobId) deleteJob(contextJobId);
if (pollingInterval) { hideContextMenu();
clearInterval(pollingInterval);
pollingInterval = null;
} }
// Mark job as active function loadJobDetails(jobId) {
document.querySelectorAll('.job-item').forEach(item => { activeJobId = jobId;
item.classList.remove('active'); if (pollingInterval) clearInterval(pollingInterval);
});
document.querySelectorAll('.job-item').forEach(item => item.classList.remove('active'));
const jobItem = document.querySelector(`[data-job-id="${jobId}"]`); const jobItem = document.querySelector(`[data-job-id="${jobId}"]`);
if (jobItem) jobItem.classList.add('active'); if (jobItem) jobItem.classList.add('active');
// Initial fetch
fetchJobDetails(jobId); fetchJobDetails(jobId);
pollingInterval = setInterval(() => fetchJobDetails(jobId, true), 5000);
// Start polling if job is not finished
pollingInterval = setInterval(() => {
fetchJobDetails(jobId, true);
}, 5000);
} }
function fetchJobDetails(jobId, isPolling = false) { function fetchJobDetails(jobId, isPolling = false) {
@@ -117,43 +182,50 @@
.then(response => response.json()) .then(response => response.json())
.then(job => { .then(job => {
const container = document.getElementById('job-details-container'); const container = document.getElementById('job-details-container');
const actions = document.getElementById('job-actions');
actions.style.display = 'flex';
const btnAbort = document.getElementById('btn-abort');
if (['passed', 'failed', 'aborted'].includes(job.status)) {
btnAbort.style.display = 'none';
if (isPolling) clearInterval(pollingInterval);
} else {
btnAbort.style.display = 'block';
}
const scenarios = JSON.parse(job.scenarios || '[]'); const scenarios = JSON.parse(job.scenarios || '[]');
const taskIds = JSON.parse(job.remote_task_ids || '{}'); const taskIds = JSON.parse(job.remote_task_ids || '{}');
const results = JSON.parse(job.remote_results || '{}'); const results = JSON.parse(job.remote_results || '{}');
// If polling and job is finished, stop polling
if (isPolling && ['passed', 'failed', 'aborted'].includes(job.status)) {
clearInterval(pollingInterval);
pollingInterval = null;
}
// Update job item icon in the list if it changed
const jobItem = document.querySelector(`[data-job-id="${jobId}"]`);
if (jobItem) {
// We need to fetch the icon separately or update it here
// For now, let's just update the details panel
}
let html = ` let html = `
<div class="detail-row"> <div class="detail-row">
<div class="detail-label">Job ID:</div> <div class="detail-label">Job ID:</div>
<div class="detail-value">#${job.id} (Remote: ${job.remote_queue_id || 'N/A'})</div> <div class="detail-value">#${job.id} (Remote: ${job.remote_queue_id || 'N/A'})</div>
</div> </div>
<div class="detail-row">
<div class="detail-label">Submitter:</div>
<div class="detail-value">${job.submitter}</div>
</div>
<div class="detail-row"> <div class="detail-row">
<div class="detail-label">Branch:</div> <div class="detail-label">Branch:</div>
<div class="detail-value">${job.branch_name}</div> <div class="detail-value">${job.branch_name}</div>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<div class="detail-label">Status:</div> <div class="detail-label">Status:</div>
<div class="detail-value" id="job-status-badge"> <div class="detail-value">
<span class="status-badge status-${job.status}">${job.status.replace('_', ' ').toUpperCase()}</span> <span class="status-badge status-${job.status}">${job.status.replace('_', ' ').toUpperCase()}</span>
</div> </div>
</div> </div>
<div style="margin-top: 30px;"> <div class="tabs">
<div class="tab ${currentActiveTab === 'scenarios' ? 'active' : ''}" onclick="switchTab('scenarios')">Scenario Summary</div>
<div class="tab ${currentActiveTab === 'logs' ? 'active' : ''}" onclick="switchTab('logs')">Queue Logging</div>
</div>
<div id="scenarios-tab" class="tab-content ${currentActiveTab === 'scenarios' ? 'active' : ''}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h4 style="color: var(--dark);">Scenario Summary</h4> <h4 style="color: var(--dark);">Scenarios</h4>
<input type="text" id="scenarioSearch" placeholder="Search scenarios..." onkeyup="filterScenarios()" class="form-control" style="width: 200px; padding: 6px 12px;"> <input type="text" id="scenarioSearch" placeholder="Filter scenarios..." onkeyup="filterScenarios()" class="form-control" style="width: 200px; padding: 6px 12px;">
</div> </div>
<table class="user-table" id="scenarioTable"> <table class="user-table" id="scenarioTable">
<thead> <thead>
@@ -168,12 +240,12 @@
scenarios.forEach(s => { scenarios.forEach(s => {
const taskId = taskIds[s] || 'N/A'; const taskId = taskIds[s] || 'N/A';
const result = results[s]; // [status, link] const result = results[s];
let statusHtml = '⌛ Waiting'; let statusHtml = '⌛ Waiting';
if (result) { if (result) {
const icon = result[0] === 'PASS' ? '✅' : '❌'; const icon = result[0] === 'PASS' ? '✅' : (result[0] === 'FAIL' ? '❌' : '⚫');
const color = result[0] === 'PASS' ? 'var(--success)' : 'var(--danger)'; const color = result[0] === 'PASS' ? 'var(--success)' : (result[0] === 'FAIL' ? 'var(--danger)' : 'var(--gray)');
statusHtml = `<a href="${result[1]}" target="_blank" style="text-decoration: none; color: ${color}; font-weight: 600;">${icon} ${result[0]}</a>`; statusHtml = `<a href="${result[1]}" target="_blank" style="text-decoration: none; color: ${color}; font-weight: 600;">${icon} ${result[0]}</a>`;
} else if (job.status === 'in_progress') { } else if (job.status === 'in_progress') {
statusHtml = '🔄 Running'; statusHtml = '🔄 Running';
@@ -193,28 +265,22 @@
</table> </table>
</div> </div>
<div style="margin-top: 30px;"> <div id="logs-tab" class="tab-content ${currentActiveTab === 'logs' ? 'active' : ''}">
<h4 style="color: var(--dark); margin-bottom: 15px;">Queue Logging</h4> <div id="queue-log" style="background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 8px; font-family: 'Consolas', monospace; font-size: 12px; max-height: 500px; overflow-y: auto; white-space: pre-wrap;">${job.queue_log || 'Waiting for logs...'}</div>
<div id="queue-log" style="background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 8px; font-family: 'Consolas', monospace; font-size: 12px; max-height: 400px; overflow-y: auto; white-space: pre-wrap;">${job.queue_log || 'Waiting for logs...'}</div>
</div>
<div style="margin-top: 30px; display: flex; gap: 10px;">
<button class="btn btn-danger" onclick="abortJob(${job.id})" style="width: auto;">Abort Job</button>
</div> </div>
`; `;
container.innerHTML = html; container.innerHTML = html;
// Scroll log to bottom if it's being updated if (currentActiveTab === 'logs') {
const logElement = document.getElementById('queue-log'); const logElement = document.getElementById('queue-log');
if (logElement) logElement.scrollTop = logElement.scrollHeight; if (logElement) logElement.scrollTop = logElement.scrollHeight;
}
// If in progress, trigger status update on backend
if (!['passed', 'failed', 'aborted'].includes(job.status)) { if (!['passed', 'failed', 'aborted'].includes(job.status)) {
fetch(`/jobs/${jobId}/status`) fetch(`/jobs/${jobId}/status`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
// Update icon in list
const iconEl = document.querySelector(`[data-job-id="${jobId}"] .job-status-icon`); const iconEl = document.querySelector(`[data-job-id="${jobId}"] .job-status-icon`);
if (iconEl) iconEl.textContent = data.status_icon; if (iconEl) iconEl.textContent = data.status_icon;
}); });
@@ -222,21 +288,29 @@
}); });
} }
function filterScenarios() { function switchTab(tabName) {
const input = document.getElementById('scenarioSearch'); currentActiveTab = tabName;
const filter = input.value.toUpperCase(); document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
const table = document.getElementById('scenarioTable'); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const tr = table.getElementsByTagName('tr');
if (tabName === 'scenarios') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('scenarios-tab').classList.add('active');
} else {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('logs-tab').classList.add('active');
const logElement = document.getElementById('queue-log');
if (logElement) logElement.scrollTop = logElement.scrollHeight;
}
}
function filterScenarios() {
const filter = document.getElementById('scenarioSearch').value.toUpperCase();
const tr = document.getElementById('scenarioTable').getElementsByTagName('tr');
for (let i = 1; i < tr.length; i++) { for (let i = 1; i < tr.length; i++) {
const td = tr[i].getElementsByTagName('td')[0]; const td = tr[i].getElementsByTagName('td')[0];
if (td) { if (td) {
const txtValue = td.textContent || td.innerText; tr[i].style.display = (td.textContent || td.innerText).toUpperCase().indexOf(filter) > -1 ? "" : "none";
if (txtValue.toUpperCase().indexOf(filter) > -1) {
tr[i].style.display = "";
} else {
tr[i].style.display = "none";
}
} }
} }
} }
@@ -254,5 +328,19 @@
}); });
} }
} }
function deleteJob(jobId) {
if (confirm('Are you sure you want to delete this job? This will also remove it from the remote server.')) {
fetch(`/jobs/${jobId}/delete`, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.error);
}
});
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -8,7 +8,7 @@ TestArena is a web-based test management and execution platform designed to orch
### 1. Web Application (Flask) ### 1. Web Application (Flask)
- **Frontend**: Built with HTML, CSS (Vanilla), and JavaScript. Provides a dashboard for monitoring jobs, submitting new tests, and viewing logs. - **Frontend**: Built with HTML, CSS (Vanilla), and JavaScript. Provides a dashboard for monitoring jobs, submitting new tests, and viewing logs.
- **Backend**: Flask server handling authentication, job management, and API requests. - **Backend**: Flask server handling authentication, job management, and API requests.
- **Database**: SQLite (default) or PostgreSQL, managed via SQLAlchemy. Stores users and job history. - **Database**: PostgreSQL (in Docker) or SQLite. Managed via SQLAlchemy. Stores users and job history.
### 2. Remote Execution Server ### 2. Remote Execution Server
- Handles the actual execution of test scenarios. - Handles the actual execution of test scenarios.
@@ -21,14 +21,19 @@ TestArena is a web-based test management and execution platform designed to orch
1. User selects a branch and validates it via SSH on the remote server. 1. User selects a branch and validates it via SSH on the remote server.
2. User selects test scenarios to run. 2. User selects test scenarios to run.
3. User reviews and submits the job. 3. User reviews and submits the job.
4. The web app creates a local `Job` record and sends a POST request to the remote `/api/queue` endpoint. 4. The web app creates a local `Job` record and sends a POST request to the remote `/api/queue` endpoint using the local Job ID as the `remote_queue_id`.
### Status Polling ### Status Polling
1. A background thread in the Flask app polls the remote server every 20 seconds for all `waiting` or `in_progress` jobs. 1. A background thread in the Flask app polls the remote server every 20 seconds for all `waiting` or `in_progress` jobs.
2. The dashboard also polls the local API every 5 seconds when a job is being viewed to provide real-time updates. 2. The dashboard also polls the local API every 5 seconds when a job is being viewed to provide real-time updates.
3. Tab persistence is handled in the frontend to ensure the user's view (Scenarios vs Logs) is maintained during polling.
### Job Search (Global)
- Users can search for jobs by **Job ID** or **Username** globally.
- By default, non-admin users only see their own jobs. Searching allows them to see others' jobs if they have the ID or username.
### Job Abort/Delete ### Job Abort/Delete
- Users can abort running jobs or delete them entirely. These actions are synchronized with the remote server. - Users can abort running jobs or delete them entirely. These actions are synchronized with the remote server using the `POST /abort/{id}` and `DELETE /delete/{id}` APIs.
## Technology Stack ## Technology Stack
- **Backend**: Python, Flask, Flask-SQLAlchemy, Flask-Login, Requests. - **Backend**: Python, Flask, Flask-SQLAlchemy, Flask-Login, Requests.

View File

@@ -16,13 +16,17 @@ Access the TestArena dashboard and log in with your credentials. Default admin c
### Dashboard ### Dashboard
The dashboard shows a list of all test jobs. You can see the status (Waiting, In Progress, Passed, Failed, Aborted) and who submitted the job. The dashboard shows a list of all test jobs. You can see the status (Waiting, In Progress, Passed, Failed, Aborted) and who submitted the job.
### Searching Jobs
Click the 🔍 icon in the sidebar to open the search panel.
- **Search by Job ID**: Enter a Job ID to find a specific job globally.
- **Search by Username**: Enter a username to find all jobs submitted by that user globally.
- **Clear Search**: Click "Clear" to return to your default view (your own jobs).
### Job Details ### Job Details
Click on a job to view its details: Click on a job to view its details:
- **Scenario Summary**: Shows the status of each individual scenario in the job. - **Scenario Summary Tab**: Shows the status of each individual scenario in the job.
- **Queue Logging**: Real-time logs from the execution queue. - **Queue Logging Tab**: Real-time logs from the execution queue. The view persists even when the page updates.
- **Submitter Info**: The username of the person who submitted the job is listed in the details.
### Searching
Use the search boxes in the sidebar to find jobs by **Job ID** or **Username**. Admins can see all jobs, while regular users see their own by default.
## Managing Jobs ## Managing Jobs
@@ -30,4 +34,4 @@ Use the search boxes in the sidebar to find jobs by **Job ID** or **Username**.
If a job is running, you can abort it by clicking the **Abort** button in the job details or via the right-click context menu in the job list. If a job is running, you can abort it by clicking the **Abort** button in the job details or via the right-click context menu in the job list.
### Deleting a Job ### Deleting a Job
To remove a job from the history and the remote server, click the **Delete** button in the job details or via the right-click context menu. To remove a job from the history and the remote server, click the **Delete** button in the job details or via the right-click context menu. This will permanently delete the job data from both the local database and the remote execution server.