fix loading issue
This commit is contained in:
@@ -4,10 +4,38 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-container">
|
||||
<div class="panel">
|
||||
<div class="panel sidebar-panel">
|
||||
<div class="panel-header">
|
||||
<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 class="job-list">
|
||||
@@ -24,16 +52,20 @@
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No jobs yet</h3>
|
||||
<p>Submit your first test job to get started</p>
|
||||
<h3>No jobs found</h3>
|
||||
<p>Try a different search or submit a new job</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel details-panel">
|
||||
<div class="panel-header">
|
||||
<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 id="job-details-container">
|
||||
@@ -47,31 +79,73 @@
|
||||
|
||||
<!-- 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>
|
||||
Abort Job
|
||||
</div>
|
||||
<div class="context-menu-item" onclick="deleteJobFromContext()">
|
||||
<span class="context-menu-icon">🗑️</span>
|
||||
Delete Job
|
||||
</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>
|
||||
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) {
|
||||
event.preventDefault();
|
||||
|
||||
// Only show context menu for in_progress jobs
|
||||
if (status !== 'in_progress') {
|
||||
return;
|
||||
}
|
||||
|
||||
contextJobId = jobId;
|
||||
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.left = event.pageX + 'px';
|
||||
contextMenu.style.top = event.pageY + 'px';
|
||||
|
||||
// Hide context menu when clicking elsewhere
|
||||
document.addEventListener('click', hideContextMenu);
|
||||
}
|
||||
|
||||
@@ -82,34 +156,25 @@
|
||||
}
|
||||
|
||||
function abortJobFromContext() {
|
||||
if (contextJobId) {
|
||||
abortJob(contextJobId);
|
||||
}
|
||||
if (contextJobId) abortJob(contextJobId);
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function deleteJobFromContext() {
|
||||
if (contextJobId) deleteJob(contextJobId);
|
||||
hideContextMenu();
|
||||
}
|
||||
let pollingInterval = null;
|
||||
|
||||
function loadJobDetails(jobId) {
|
||||
// Clear existing polling
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
pollingInterval = null;
|
||||
}
|
||||
activeJobId = jobId;
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
|
||||
// Mark job as active
|
||||
document.querySelectorAll('.job-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.job-item').forEach(item => item.classList.remove('active'));
|
||||
const jobItem = document.querySelector(`[data-job-id="${jobId}"]`);
|
||||
if (jobItem) jobItem.classList.add('active');
|
||||
|
||||
// Initial fetch
|
||||
fetchJobDetails(jobId);
|
||||
|
||||
// Start polling if job is not finished
|
||||
pollingInterval = setInterval(() => {
|
||||
fetchJobDetails(jobId, true);
|
||||
}, 5000);
|
||||
pollingInterval = setInterval(() => fetchJobDetails(jobId, true), 5000);
|
||||
}
|
||||
|
||||
function fetchJobDetails(jobId, isPolling = false) {
|
||||
@@ -117,43 +182,50 @@
|
||||
.then(response => response.json())
|
||||
.then(job => {
|
||||
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 taskIds = JSON.parse(job.remote_task_ids || '{}');
|
||||
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 = `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Job ID:</div>
|
||||
<div class="detail-value">#${job.id} (Remote: ${job.remote_queue_id || 'N/A'})</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-label">Branch:</div>
|
||||
<div class="detail-value">${job.branch_name}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<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>
|
||||
</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;">
|
||||
<h4 style="color: var(--dark);">Scenario Summary</h4>
|
||||
<input type="text" id="scenarioSearch" placeholder="Search scenarios..." onkeyup="filterScenarios()" class="form-control" style="width: 200px; padding: 6px 12px;">
|
||||
<h4 style="color: var(--dark);">Scenarios</h4>
|
||||
<input type="text" id="scenarioSearch" placeholder="Filter scenarios..." onkeyup="filterScenarios()" class="form-control" style="width: 200px; padding: 6px 12px;">
|
||||
</div>
|
||||
<table class="user-table" id="scenarioTable">
|
||||
<thead>
|
||||
@@ -164,28 +236,28 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
`;
|
||||
|
||||
scenarios.forEach(s => {
|
||||
const taskId = taskIds[s] || 'N/A';
|
||||
const result = results[s]; // [status, link]
|
||||
const result = results[s];
|
||||
let statusHtml = '⌛ Waiting';
|
||||
|
||||
if (result) {
|
||||
const icon = result[0] === 'PASS' ? '✅' : '❌';
|
||||
const color = result[0] === 'PASS' ? 'var(--success)' : 'var(--danger)';
|
||||
const icon = result[0] === 'PASS' ? '✅' : (result[0] === 'FAIL' ? '❌' : '⚫');
|
||||
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>`;
|
||||
} else if (job.status === 'in_progress') {
|
||||
statusHtml = '🔄 Running';
|
||||
}
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>${s}</td>
|
||||
<td><small>${taskId}</small></td>
|
||||
<td>${statusHtml}</td>
|
||||
</tr>
|
||||
`;
|
||||
<tr>
|
||||
<td>${s}</td>
|
||||
<td><small>${taskId}</small></td>
|
||||
<td>${statusHtml}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
@@ -193,28 +265,22 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px;">
|
||||
<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: 400px; overflow-y: auto; white-space: pre-wrap;">${job.queue_log || 'Waiting for logs...'}</div>
|
||||
<div id="logs-tab" class="tab-content ${currentActiveTab === 'logs' ? 'active' : ''}">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
`;
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Scroll log to bottom if it's being updated
|
||||
const logElement = document.getElementById('queue-log');
|
||||
if (logElement) logElement.scrollTop = logElement.scrollHeight;
|
||||
if (currentActiveTab === 'logs') {
|
||||
const logElement = document.getElementById('queue-log');
|
||||
if (logElement) logElement.scrollTop = logElement.scrollHeight;
|
||||
}
|
||||
|
||||
// If in progress, trigger status update on backend
|
||||
if (!['passed', 'failed', 'aborted'].includes(job.status)) {
|
||||
fetch(`/jobs/${jobId}/status`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Update icon in list
|
||||
const iconEl = document.querySelector(`[data-job-id="${jobId}"] .job-status-icon`);
|
||||
if (iconEl) iconEl.textContent = data.status_icon;
|
||||
});
|
||||
@@ -222,21 +288,29 @@
|
||||
});
|
||||
}
|
||||
|
||||
function filterScenarios() {
|
||||
const input = document.getElementById('scenarioSearch');
|
||||
const filter = input.value.toUpperCase();
|
||||
const table = document.getElementById('scenarioTable');
|
||||
const tr = table.getElementsByTagName('tr');
|
||||
function switchTab(tabName) {
|
||||
currentActiveTab = tabName;
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
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++) {
|
||||
const td = tr[i].getElementsByTagName('td')[0];
|
||||
if (td) {
|
||||
const txtValue = td.textContent || td.innerText;
|
||||
if (txtValue.toUpperCase().indexOf(filter) > -1) {
|
||||
tr[i].style.display = "";
|
||||
} else {
|
||||
tr[i].style.display = "none";
|
||||
}
|
||||
tr[i].style.display = (td.textContent || td.innerText).toUpperCase().indexOf(filter) > -1 ? "" : "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>
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,7 @@ TestArena is a web-based test management and execution platform designed to orch
|
||||
### 1. Web Application (Flask)
|
||||
- **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.
|
||||
- **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
|
||||
- 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.
|
||||
2. User selects test scenarios to run.
|
||||
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
|
||||
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.
|
||||
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
|
||||
- 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
|
||||
- **Backend**: Python, Flask, Flask-SQLAlchemy, Flask-Login, Requests.
|
||||
|
||||
@@ -16,13 +16,17 @@ Access the TestArena dashboard and log in with your credentials. Default admin c
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
Click on a job to view its details:
|
||||
- **Scenario Summary**: Shows the status of each individual scenario in the job.
|
||||
- **Queue Logging**: Real-time logs from the execution queue.
|
||||
|
||||
### 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.
|
||||
- **Scenario Summary Tab**: Shows the status of each individual scenario in the job.
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
|
||||
### 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.
|
||||
|
||||
Reference in New Issue
Block a user