next_updates

This commit is contained in:
2025-12-28 05:52:49 +01:00
parent 986f3ae5b5
commit 6b254534f6
8 changed files with 766 additions and 533 deletions

View File

@@ -37,11 +37,18 @@ class Job(db.Model):
reuse_results = db.Column(db.Boolean, default=False) reuse_results = db.Column(db.Boolean, default=False)
results_path = db.Column(db.String(500), nullable=True) results_path = db.Column(db.String(500), nullable=True)
# Phase 2 Remote Tracking
remote_queue_id = db.Column(db.String(50), nullable=True)
remote_task_ids = db.Column(db.Text, nullable=True) # JSON string: {scenario_name: task_id}
remote_results = db.Column(db.Text, nullable=True) # JSON string: {scenario_name: [status, link]}
queue_log = db.Column(db.Text, nullable=True)
def get_status_icon(self): def get_status_icon(self):
icons = { icons = {
'in_progress': '🟠', 'waiting': '',
'passed': '🟢', 'in_progress': '🔄',
'failed': '🔴', 'passed': '',
'failed': '',
'aborted': '' 'aborted': ''
} }
return icons.get(self.status, '') return icons.get(self.status, '')

View File

@@ -5,14 +5,15 @@ from app import db
import json import json
import subprocess import subprocess
import os import os
import requests
import random
import string
def generate_remote_id(length=6):
return ''.join(random.choices(string.digits, k=length))
jobs_bp = Blueprint('jobs', __name__, url_prefix='/jobs') jobs_bp = Blueprint('jobs', __name__, url_prefix='/jobs')
@jobs_bp.route('/submit')
@login_required
def submit():
return render_template('jobs/submit.html')
@jobs_bp.route('/debug/test-step2') @jobs_bp.route('/debug/test-step2')
@login_required @login_required
def debug_test_step2(): def debug_test_step2():
@@ -270,7 +271,7 @@ def submit_step2():
flash('Please select at least one scenario', 'error') flash('Please select at least one scenario', 'error')
return redirect(url_for('jobs.submit')) return redirect(url_for('jobs.submit'))
return render_template('jobs/submit_step3.html', return render_template('jobs/submit_review.html',
branch_name=branch_name, branch_name=branch_name,
scenarios=selected_scenarios, scenarios=selected_scenarios,
scenario_map=scenario_map) scenario_map=scenario_map)
@@ -318,50 +319,14 @@ def submit_step2_validated():
print(f"[ERROR] Step2 - Traceback: {traceback.format_exc()}") print(f"[ERROR] Step2 - Traceback: {traceback.format_exc()}")
flash(f'Error loading scenario selection: {str(e)}', 'error') flash(f'Error loading scenario selection: {str(e)}', 'error')
return redirect(url_for('jobs.submit')) return redirect(url_for('jobs.submit'))
@login_required
def submit_step2():
branch_name = request.form.get('branch_name')
selected_scenarios = request.form.getlist('scenarios')
if not selected_scenarios:
flash('Please select at least one scenario', 'error')
return redirect(url_for('jobs.submit'))
return render_template('jobs/submit_step3.html',
branch_name=branch_name,
scenarios=selected_scenarios)
@jobs_bp.route('/submit/step3', methods=['POST'])
@login_required
def submit_step3():
branch_name = request.form.get('branch_name')
scenarios_json = request.form.get('scenarios')
scenario_map_json = request.form.get('scenario_map')
environment = request.form.get('environment')
try:
scenarios = json.loads(scenarios_json) if scenarios_json else []
scenario_map = json.loads(scenario_map_json) if scenario_map_json else {}
except json.JSONDecodeError:
flash('Invalid scenario data', 'error')
return redirect(url_for('jobs.submit'))
return render_template('jobs/submit_step4.html',
branch_name=branch_name,
scenarios=scenarios,
scenario_map=scenario_map,
environment=environment)
@jobs_bp.route('/submit/final', methods=['POST']) @jobs_bp.route('/submit/final', methods=['POST'])
@login_required @login_required
def submit_final(): def submit_final():
branch_name = request.form.get('branch_name') branch_name = request.form.get('branch_name')
scenarios_json = request.form.get('scenarios') scenarios_json = request.form.get('scenarios')
scenario_map_json = request.form.get('scenario_map') scenario_map_json = request.form.get('scenario_map')
environment = request.form.get('environment') environment = request.form.get('environment', 'staging')
test_mode = request.form.get('test_mode') test_mode = request.form.get('test_mode', 'simulator')
keep_devbenches = request.form.get('keep_devbenches') == 'on'
reuse_results = request.form.get('reuse_results') == 'on'
try: try:
scenarios = json.loads(scenarios_json) if scenarios_json else [] scenarios = json.loads(scenarios_json) if scenarios_json else []
@@ -370,22 +335,44 @@ def submit_final():
flash('Invalid scenario data', 'error') flash('Invalid scenario data', 'error')
return redirect(url_for('jobs.submit')) return redirect(url_for('jobs.submit'))
# Generate Remote IDs
remote_queue_id = generate_remote_id()
remote_task_ids = {s: generate_remote_id() for s in scenarios}
# Prepare Remote Payload
# Format: {"source": "branch", "queue_id": ["staging", {"task_id": "path"}]}
payload = {
"source": branch_name,
remote_queue_id: [
environment,
{remote_task_ids[s]: scenario_map.get(s, s) for s in scenarios}
]
}
# Trigger Remote Queue
try:
remote_url = "http://asf-server.duckdns.org:8080/api/queue"
response = requests.post(remote_url, json=payload, timeout=10)
response.raise_for_status()
print(f"[DEBUG] Remote queue triggered: {response.json()}")
except Exception as e:
print(f"[ERROR] Failed to trigger remote queue: {e}")
flash(f'Warning: Job saved but failed to trigger remote queue: {str(e)}', 'warning')
job = Job( job = Job(
user_id=current_user.id, user_id=current_user.id,
branch_name=branch_name, branch_name=branch_name,
scenarios=json.dumps(scenarios), # Store as JSON string scenarios=json.dumps(scenarios),
environment=environment, environment=environment,
test_mode=test_mode, test_mode=test_mode,
keep_devbenches=keep_devbenches, status='waiting', # Start with waiting (sand watch)
reuse_results=reuse_results, remote_queue_id=remote_queue_id,
status='in_progress' remote_task_ids=json.dumps(remote_task_ids)
) )
db.session.add(job) db.session.add(job)
db.session.commit() db.session.commit()
# TODO: Start test execution in background using scenario_map
flash('Test job submitted successfully', 'success') flash('Test job submitted successfully', 'success')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -409,23 +396,96 @@ def view_job(job_id):
'submitted_at': job.submitted_at.strftime('%Y-%m-%d %H:%M:%S'), 'submitted_at': job.submitted_at.strftime('%Y-%m-%d %H:%M:%S'),
'completed_at': job.completed_at.strftime('%Y-%m-%d %H:%M:%S') if job.completed_at else None, 'completed_at': job.completed_at.strftime('%Y-%m-%d %H:%M:%S') if job.completed_at else None,
'duration': job.duration, 'duration': job.duration,
'keep_devbenches': job.keep_devbenches, 'remote_queue_id': job.remote_queue_id,
'reuse_results': job.reuse_results, 'remote_task_ids': job.remote_task_ids,
'results_path': job.results_path 'remote_results': job.remote_results,
'queue_log': job.queue_log
}) })
@jobs_bp.route('/<int:job_id>/abort', methods=['POST']) @jobs_bp.route('/<int:job_id>/abort', methods=['POST'])
@login_required @login_required
def abort_job(job_id): def abort_job(job_id):
return jsonify({'error': 'Abort functionality is not implemented yet'}), 400
@jobs_bp.route('/<int:job_id>/status')
@login_required
def get_job_status(job_id):
job = Job.query.get_or_404(job_id) job = Job.query.get_or_404(job_id)
if not current_user.is_admin and job.user_id != current_user.id: if job.status not in ['passed', 'failed', 'aborted']:
return jsonify({'error': 'Access denied'}), 403 # Poll remote API
try:
# 1. Check Queue Status
status_url = f"http://asf-server.duckdns.org:8080/api/status/{job.remote_queue_id}"
q_resp = requests.get(status_url, timeout=5)
if q_resp.status_code == 200:
q_data = q_resp.json()
remote_status = q_data.get('status', '').lower()
if job.status == 'in_progress': if remote_status == 'running':
job.status = 'aborted' job.status = 'in_progress'
elif remote_status == 'waiting':
job.status = 'waiting'
# 2. Fetch Queue Logs if running
if remote_status == 'running':
log_url = f"http://asf-server.duckdns.org:8080/results/{job.remote_queue_id}/queue_log.txt"
l_resp = requests.get(log_url, timeout=5)
if l_resp.status_code == 200:
job.queue_log = l_resp.text
# 3. Check Task Statuses and Results
task_ids = json.loads(job.remote_task_ids) if job.remote_task_ids else {}
results = json.loads(job.remote_results) if job.remote_results else {}
all_finished = True
any_failed = False
for scenario, task_id in task_ids.items():
# If we don't have result yet, check it
if scenario not in results:
# Check task status
t_status_url = f"http://asf-server.duckdns.org:8080/api/status/{task_id}"
t_resp = requests.get(t_status_url, timeout=2)
if t_resp.status_code == 200:
t_data = t_resp.json()
if t_data.get('status') == 'Finished':
# Fetch results
res_url = f"http://asf-server.duckdns.org:8080/results/{job.remote_queue_id}/{task_id}/final_summary.json"
r_resp = requests.get(res_url, timeout=2)
if r_resp.status_code == 200:
r_data = r_resp.json()
# User says: {"SCENARIO_NAME": ["PASS/FAIL", "link"]}
# We need to find the key that matches our scenario (case insensitive maybe?)
for key, val in r_data.items():
if key.upper() == scenario.upper():
results[scenario] = val
if val[0] == 'FAIL':
any_failed = True
break
else:
all_finished = False
else:
all_finished = False
else:
all_finished = False
else:
if results[scenario][0] == 'FAIL':
any_failed = True
if all_finished and task_ids:
job.status = 'failed' if any_failed else 'passed'
job.completed_at = db.session.query(db.func.now()).scalar()
job.remote_results = json.dumps(results)
db.session.commit() db.session.commit()
# TODO: Kill the running process
return jsonify({'success': True})
return jsonify({'error': 'Job is not in progress'}), 400 except Exception as e:
print(f"[ERROR] Status polling failed: {e}")
return jsonify({
'status': job.status,
'status_icon': job.get_status_icon(),
'remote_results': job.remote_results,
'queue_log': job.queue_log
})

View File

@@ -623,8 +623,8 @@ body {
background: #9ca3af; background: #9ca3af;
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
}/ }
* Scenario Tree Styles */ /* Scenario Tree Styles */
.scenario-controls { .scenario-controls {
margin: 20px 0; margin: 20px 0;
padding: 15px; padding: 15px;
@@ -754,3 +754,33 @@ input[type="checkbox"]:indeterminate::before {
color: var(--primary); color: var(--primary);
font-size: 14px; font-size: 14px;
} }
/* Scenario Table & Log Styles */
#scenarioTable {
margin-top: 10px;
font-size: 13px;
}
#scenarioTable th {
padding: 10px;
background: #f8fafc;
}
#scenarioTable td {
padding: 10px;
}
#queue-log {
border: 1px solid #333;
box-shadow: inset 0 2px 10px rgba(0,0,0,0.5);
}
#scenarioSearch:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.status-waiting {
background: #fef3c7;
color: #92400e;
}

View File

@@ -13,7 +13,8 @@
<div class="job-list"> <div class="job-list">
{% if jobs %} {% if jobs %}
{% for job in jobs %} {% for job in jobs %}
<div class="job-item" data-job-id="{{ job.id }}" onclick="loadJobDetails({{ job.id }})" oncontextmenu="showContextMenu(event, {{ job.id }}, '{{ job.status }}')"> <div class="job-item" data-job-id="{{ job.id }}" onclick="loadJobDetails({{ job.id }})"
oncontextmenu="showContextMenu(event, {{ job.id }}, '{{ job.status }}')">
<div class="job-status-icon">{{ job.get_status_icon() }}</div> <div class="job-status-icon">{{ job.get_status_icon() }}</div>
<div class="job-info"> <div class="job-info">
<h4>Job #{{ job.id }} - {{ job.branch_name }}</h4> <h4>Job #{{ job.id }} - {{ job.branch_name }}</h4>
@@ -53,9 +54,9 @@
</div> </div>
<script> <script>
let contextJobId = null; let contextJobId = null;
function showContextMenu(event, jobId, status) { function showContextMenu(event, jobId, status) {
event.preventDefault(); event.preventDefault();
// Only show context menu for in_progress jobs // Only show context menu for in_progress jobs
@@ -72,42 +73,71 @@ function showContextMenu(event, jobId, status) {
// Hide context menu when clicking elsewhere // Hide context menu when clicking elsewhere
document.addEventListener('click', hideContextMenu); document.addEventListener('click', hideContextMenu);
} }
function hideContextMenu() { function hideContextMenu() {
document.getElementById('contextMenu').style.display = 'none'; document.getElementById('contextMenu').style.display = 'none';
document.removeEventListener('click', hideContextMenu); document.removeEventListener('click', hideContextMenu);
contextJobId = null; contextJobId = null;
} }
function abortJobFromContext() { function abortJobFromContext() {
if (contextJobId) { if (contextJobId) {
abortJob(contextJobId); abortJob(contextJobId);
} }
hideContextMenu(); hideContextMenu();
} }
function loadJobDetails(jobId) { let pollingInterval = null;
function loadJobDetails(jobId) {
// Clear existing polling
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
// Mark job as active // Mark job as active
document.querySelectorAll('.job-item').forEach(item => { document.querySelectorAll('.job-item').forEach(item => {
item.classList.remove('active'); item.classList.remove('active');
}); });
document.querySelector(`[data-job-id="${jobId}"]`).classList.add('active'); const jobItem = document.querySelector(`[data-job-id="${jobId}"]`);
if (jobItem) jobItem.classList.add('active');
// Fetch job details // Initial fetch
fetchJobDetails(jobId);
// Start polling if job is not finished
pollingInterval = setInterval(() => {
fetchJobDetails(jobId, true);
}, 5000);
}
function fetchJobDetails(jobId, isPolling = false) {
fetch(`/jobs/${jobId}`) fetch(`/jobs/${jobId}`)
.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 scenarios = JSON.parse(job.scenarios || '[]'); const scenarios = JSON.parse(job.scenarios || '[]');
const taskIds = JSON.parse(job.remote_task_ids || '{}');
const results = JSON.parse(job.remote_results || '{}');
container.innerHTML = ` // 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-row">
<div class="detail-label">Job ID:</div> <div class="detail-label">Job ID:</div>
<div class="detail-value">#${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>
<div class="detail-row"> <div class="detail-row">
<div class="detail-label">Branch:</div> <div class="detail-label">Branch:</div>
@@ -115,51 +145,103 @@ function loadJobDetails(jobId) {
</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"> <div class="detail-value" id="job-status-badge">
<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 class="detail-row">
<div class="detail-label">Environment:</div> <div style="margin-top: 30px;">
<div class="detail-value">${job.environment}</div> <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;">
</div> </div>
<div class="detail-row"> <table class="user-table" id="scenarioTable">
<div class="detail-label">Test Mode:</div> <thead>
<div class="detail-value">${job.test_mode}</div> <tr>
</div> <th>Scenario Name</th>
<div class="detail-row"> <th>Task ID</th>
<div class="detail-label">Submitted:</div> <th>Status</th>
<div class="detail-value">${job.submitted_at}</div> </tr>
</div> </thead>
${job.completed_at ? ` <tbody>
<div class="detail-row"> `;
<div class="detail-label">Completed:</div>
<div class="detail-value">${job.completed_at}</div> scenarios.forEach(s => {
</div> const taskId = taskIds[s] || 'N/A';
<div class="detail-row"> const result = results[s]; // [status, link]
<div class="detail-label">Duration:</div> let statusHtml = '⌛ Waiting';
<div class="detail-value">${job.duration ? Math.floor(job.duration / 60) + 'm ' + (job.duration % 60) + 's' : 'N/A'}</div>
</div> if (result) {
` : ''} const icon = result[0] === 'PASS' ? '✅' : '';
<div class="detail-row"> const color = result[0] === 'PASS' ? 'var(--success)' : 'var(--danger)';
<div class="detail-label">Scenarios:</div> statusHtml = `<a href="${result[1]}" target="_blank" style="text-decoration: none; color: ${color}; font-weight: 600;">${icon} ${result[0]}</a>`;
<div class="detail-value">${Array.isArray(scenarios) ? scenarios.join(', ') : scenarios}</div> } else if (job.status === 'in_progress') {
</div> statusHtml = '🔄 Running';
${job.status === 'in_progress' ? ` }
<div style="margin-top: 20px;">
<button class="btn btn-danger" onclick="abortJob(${job.id})">Abort Job</button> html += `
</div> <tr>
` : ''} <td>${s}</td>
${job.results_path ? ` <td><small>${taskId}</small></td>
<div style="margin-top: 20px;"> <td>${statusHtml}</td>
<a href="${job.results_path}" class="btn btn-primary" target="_blank">View Results</a> </tr>
</div>
` : ''}
`; `;
}); });
}
function abortJob(jobId) { html += `
</tbody>
</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>
<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 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;
});
}
});
}
function filterScenarios() {
const input = document.getElementById('scenarioSearch');
const filter = input.value.toUpperCase();
const table = document.getElementById('scenarioTable');
const tr = table.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";
}
}
}
}
function abortJob(jobId) {
if (confirm('Are you sure you want to abort this job?')) { if (confirm('Are you sure you want to abort this job?')) {
fetch(`/jobs/${jobId}/abort`, { method: 'POST' }) fetch(`/jobs/${jobId}/abort`, { method: 'POST' })
.then(response => response.json()) .then(response => response.json())
@@ -171,6 +253,6 @@ function abortJob(jobId) {
} }
}); });
} }
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -17,14 +17,6 @@
</div> </div>
<div class="step"> <div class="step">
<div class="step-number">3</div> <div class="step-number">3</div>
<div class="step-label">Environment</div>
</div>
<div class="step">
<div class="step-number">4</div>
<div class="step-label">Test Mode</div>
</div>
<div class="step">
<div class="step-number">5</div>
<div class="step-label">Review</div> <div class="step-label">Review</div>
</div> </div>
</div> </div>
@@ -47,22 +39,23 @@
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('dashboard.index') }}" class="btn" style="background: #6b7280; color: white;">Cancel</a> <a href="{{ url_for('dashboard.index') }}" class="btn" style="background: #6b7280; color: white;">Cancel</a>
<button type="submit" id="validateBtn" class="btn btn-primary">Validate Branch</button> <button type="submit" id="validateBtn" class="btn btn-primary">Validate Branch</button>
<button type="button" id="nextBtn" class="btn btn-primary" style="display: none;" onclick="proceedToStep2()">Next</button> <button type="button" id="nextBtn" class="btn btn-primary" style="display: none;"
onclick="proceedToStep2()">Next</button>
</div> </div>
</form> </form>
</div> </div>
<script> <script>
let validatedBranch = null; let validatedBranch = null;
let organizedData = {}; let organizedData = {};
let scenarioMap = {}; let scenarioMap = {};
document.getElementById('branchForm').addEventListener('submit', function(e) { document.getElementById('branchForm').addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
validateBranch(); validateBranch();
}); });
function validateBranch() { function validateBranch() {
const branchName = document.getElementById('branch_name').value.trim(); const branchName = document.getElementById('branch_name').value.trim();
if (!branchName) { if (!branchName) {
@@ -143,9 +136,9 @@ function validateBranch() {
validatedBranch = null; validatedBranch = null;
availableScenarios = []; availableScenarios = [];
}); });
} }
function proceedToStep2() { function proceedToStep2() {
if (!validatedBranch || Object.keys(organizedData).length === 0) { if (!validatedBranch || Object.keys(organizedData).length === 0) {
alert('Please validate the branch first'); alert('Please validate the branch first');
return; return;
@@ -176,10 +169,10 @@ function proceedToStep2() {
document.body.appendChild(form); document.body.appendChild(form);
form.submit(); form.submit();
} }
// Reset validation when branch name changes // Reset validation when branch name changes
document.getElementById('branch_name').addEventListener('input', function() { document.getElementById('branch_name').addEventListener('input', function () {
const validation = document.getElementById('branchValidation'); const validation = document.getElementById('branchValidation');
const validateBtn = document.getElementById('validateBtn'); const validateBtn = document.getElementById('validateBtn');
const nextBtn = document.getElementById('nextBtn'); const nextBtn = document.getElementById('nextBtn');
@@ -192,6 +185,6 @@ document.getElementById('branch_name').addEventListener('input', function() {
validatedBranch = null; validatedBranch = null;
organizedData = {}; organizedData = {};
scenarioMap = {}; scenarioMap = {};
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Review & Submit - ASF TestArena{% endblock %}
{% block content %}
<div class="submit-container">
<h2 style="margin-bottom: 30px; color: var(--dark);">Review & Submit</h2>
<div class="step-indicator">
<div class="step completed">
<div class="step-number"></div>
<div class="step-label">Branch</div>
</div>
<div class="step completed">
<div class="step-number"></div>
<div class="step-label">Scenarios</div>
</div>
<div class="step active">
<div class="step-number">3</div>
<div class="step-label">Review</div>
</div>
</div>
<div style="background: white; border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 30px;">
<div class="detail-row">
<div class="detail-label">Branch:</div>
<div class="detail-value">{{ branch_name }}</div>
</div>
<div class="detail-row">
<div class="detail-label">Scenarios:</div>
<div class="detail-value">
<strong>{{ scenarios|length }} scenarios selected</strong>
<ul style="margin-top: 10px; padding-left: 20px; max-height: 200px; overflow-y: auto;">
{% for scenario in scenarios %}
<li style="font-size: 13px; color: #4b5563; margin-bottom: 4px;">{{ scenario }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<form method="POST" action="{{ url_for('jobs.submit_final') }}">
<input type="hidden" name="branch_name" value="{{ branch_name }}">
<input type="hidden" name="scenarios" value="{{ scenarios|tojson }}">
<input type="hidden" name="scenario_map" value="{{ scenario_map|tojson }}">
<!-- Default values for deactivated sections -->
<input type="hidden" name="environment" value="staging">
<input type="hidden" name="test_mode" value="simulator">
<div class="form-actions">
<button type="button" class="btn" style="background: #6b7280; color: white;" onclick="history.back()">Back</button>
<button type="submit" class="btn btn-success" style="padding: 12px 40px;">Submit Job</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -17,14 +17,6 @@
</div> </div>
<div class="step"> <div class="step">
<div class="step-number">3</div> <div class="step-number">3</div>
<div class="step-label">Environment</div>
</div>
<div class="step">
<div class="step-number">4</div>
<div class="step-label">Test Mode</div>
</div>
<div class="step">
<div class="step-number">5</div>
<div class="step-label">Review</div> <div class="step-label">Review</div>
</div> </div>
</div> </div>
@@ -39,8 +31,10 @@
<input type="hidden" name="selected_scenarios" id="selectedScenariosInput"> <input type="hidden" name="selected_scenarios" id="selectedScenariosInput">
<div class="scenario-controls"> <div class="scenario-controls">
<button type="button" class="btn btn-sm" onclick="selectAll()" style="background: var(--success); color: white; margin-right: 10px;">Select All</button> <button type="button" class="btn btn-sm" onclick="selectAll()"
<button type="button" class="btn btn-sm" onclick="deselectAll()" style="background: var(--danger); color: white;">Deselect All</button> style="background: var(--success); color: white; margin-right: 10px;">Select All</button>
<button type="button" class="btn btn-sm" onclick="deselectAll()"
style="background: var(--danger); color: white;">Deselect All</button>
<span id="selectionCount" style="margin-left: 20px; font-weight: 600;">0 scenarios selected</span> <span id="selectionCount" style="margin-left: 20px; font-weight: 600;">0 scenarios selected</span>
</div> </div>
@@ -50,8 +44,10 @@
<div class="tree-layer"> <div class="tree-layer">
<div class="tree-node layer-node" onclick="toggleLayer('{{ layer_name }}')"> <div class="tree-node layer-node" onclick="toggleLayer('{{ layer_name }}')">
<span class="tree-toggle" id="toggle-{{ layer_name }}"></span> <span class="tree-toggle" id="toggle-{{ layer_name }}"></span>
<input type="checkbox" class="layer-checkbox" id="layer-{{ layer_name }}" onchange="toggleLayerSelection('{{ layer_name }}')"> <input type="checkbox" class="layer-checkbox" id="layer-{{ layer_name }}"
<label for="layer-{{ layer_name }}" class="tree-label layer-label">{{ layer_name.replace('_', ' ').title() }}</label> onchange="toggleLayerSelection('{{ layer_name }}')">
<label for="layer-{{ layer_name }}" class="tree-label layer-label">{{ layer_name.replace('_', '
').title() }}</label>
</div> </div>
<div class="tree-children" id="children-{{ layer_name }}" style="display: none;"> <div class="tree-children" id="children-{{ layer_name }}" style="display: none;">
@@ -60,18 +56,24 @@
<div class="tree-stack"> <div class="tree-stack">
<div class="tree-node stack-node" onclick="toggleStack('{{ layer_name }}', '{{ stack_name }}')"> <div class="tree-node stack-node" onclick="toggleStack('{{ layer_name }}', '{{ stack_name }}')">
<span class="tree-toggle" id="toggle-{{ layer_name }}-{{ stack_name }}"></span> <span class="tree-toggle" id="toggle-{{ layer_name }}-{{ stack_name }}"></span>
<input type="checkbox" class="stack-checkbox" id="stack-{{ layer_name }}-{{ stack_name }}" onchange="toggleStackSelection('{{ layer_name }}', '{{ stack_name }}')"> <input type="checkbox" class="stack-checkbox" id="stack-{{ layer_name }}-{{ stack_name }}"
<label for="stack-{{ layer_name }}-{{ stack_name }}" class="tree-label stack-label">{{ stack_name.replace('_', ' ').title() }}</label> onchange="toggleStackSelection('{{ layer_name }}', '{{ stack_name }}')">
<label for="stack-{{ layer_name }}-{{ stack_name }}" class="tree-label stack-label">{{
stack_name.replace('_', ' ').title() }}</label>
</div> </div>
<div class="tree-children" id="children-{{ layer_name }}-{{ stack_name }}" style="display: none;"> <div class="tree-children" id="children-{{ layer_name }}-{{ stack_name }}"
style="display: none;">
{% if scenarios %} {% if scenarios %}
{% for scenario in scenarios %} {% for scenario in scenarios %}
<div class="tree-scenario"> <div class="tree-scenario">
<div class="tree-node scenario-node"> <div class="tree-node scenario-node">
<span class="tree-spacer"></span> <span class="tree-spacer"></span>
<input type="checkbox" class="scenario-checkbox" id="scenario-{{ scenario }}" value="{{ scenario }}" onchange="updateSelectionCount()" data-layer="{{ layer_name }}" data-stack="{{ stack_name }}"> <input type="checkbox" class="scenario-checkbox" id="scenario-{{ scenario }}"
<label for="scenario-{{ scenario }}" class="tree-label scenario-label">{{ scenario }}</label> value="{{ scenario }}" onchange="updateSelectionCount()"
data-layer="{{ layer_name }}" data-stack="{{ stack_name }}">
<label for="scenario-{{ scenario }}" class="tree-label scenario-label">{{ scenario
}}</label>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -79,7 +81,8 @@
<div class="tree-scenario"> <div class="tree-scenario">
<div class="tree-node scenario-node"> <div class="tree-node scenario-node">
<span class="tree-spacer"></span> <span class="tree-spacer"></span>
<span class="tree-label scenario-label" style="color: #9ca3af;">No scenarios found</span> <span class="tree-label scenario-label" style="color: #9ca3af;">No scenarios
found</span>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -114,9 +117,9 @@
</div> </div>
<script> <script>
let selectedScenarios = new Set(); let selectedScenarios = new Set();
function toggleLayer(layerName) { function toggleLayer(layerName) {
const children = document.getElementById(`children-${layerName}`); const children = document.getElementById(`children-${layerName}`);
const toggle = document.getElementById(`toggle-${layerName}`); const toggle = document.getElementById(`toggle-${layerName}`);
@@ -127,9 +130,9 @@ function toggleLayer(layerName) {
children.style.display = 'none'; children.style.display = 'none';
toggle.textContent = '▶'; toggle.textContent = '▶';
} }
} }
function toggleStack(layerName, stackName) { function toggleStack(layerName, stackName) {
const children = document.getElementById(`children-${layerName}-${stackName}`); const children = document.getElementById(`children-${layerName}-${stackName}`);
const toggle = document.getElementById(`toggle-${layerName}-${stackName}`); const toggle = document.getElementById(`toggle-${layerName}-${stackName}`);
@@ -140,9 +143,9 @@ function toggleStack(layerName, stackName) {
children.style.display = 'none'; children.style.display = 'none';
toggle.textContent = '▶'; toggle.textContent = '▶';
} }
} }
function toggleLayerSelection(layerName) { function toggleLayerSelection(layerName) {
const layerCheckbox = document.getElementById(`layer-${layerName}`); const layerCheckbox = document.getElementById(`layer-${layerName}`);
const stackCheckboxes = document.querySelectorAll(`input[id^="stack-${layerName}-"]`); const stackCheckboxes = document.querySelectorAll(`input[id^="stack-${layerName}-"]`);
const scenarioCheckboxes = document.querySelectorAll(`input[data-layer="${layerName}"]`); const scenarioCheckboxes = document.querySelectorAll(`input[data-layer="${layerName}"]`);
@@ -163,9 +166,9 @@ function toggleLayerSelection(layerName) {
}); });
updateSelectionCount(); updateSelectionCount();
} }
function toggleStackSelection(layerName, stackName) { function toggleStackSelection(layerName, stackName) {
const stackCheckbox = document.getElementById(`stack-${layerName}-${stackName}`); const stackCheckbox = document.getElementById(`stack-${layerName}-${stackName}`);
const scenarioCheckboxes = document.querySelectorAll(`input[data-layer="${layerName}"][data-stack="${stackName}"]`); const scenarioCheckboxes = document.querySelectorAll(`input[data-layer="${layerName}"][data-stack="${stackName}"]`);
@@ -181,9 +184,9 @@ function toggleStackSelection(layerName, stackName) {
updateLayerState(layerName); updateLayerState(layerName);
updateSelectionCount(); updateSelectionCount();
} }
function updateLayerState(layerName) { function updateLayerState(layerName) {
const layerCheckbox = document.getElementById(`layer-${layerName}`); const layerCheckbox = document.getElementById(`layer-${layerName}`);
const stackCheckboxes = document.querySelectorAll(`input[id^="stack-${layerName}-"]`); const stackCheckboxes = document.querySelectorAll(`input[id^="stack-${layerName}-"]`);
const scenarioCheckboxes = document.querySelectorAll(`input[data-layer="${layerName}"]`); const scenarioCheckboxes = document.querySelectorAll(`input[data-layer="${layerName}"]`);
@@ -218,9 +221,9 @@ function updateLayerState(layerName) {
layerCheckbox.checked = false; layerCheckbox.checked = false;
layerCheckbox.indeterminate = true; layerCheckbox.indeterminate = true;
} }
} }
function updateSelectionCount() { function updateSelectionCount() {
// Update selected scenarios set // Update selected scenarios set
selectedScenarios.clear(); selectedScenarios.clear();
document.querySelectorAll('.scenario-checkbox:checked').forEach(checkbox => { document.querySelectorAll('.scenario-checkbox:checked').forEach(checkbox => {
@@ -241,28 +244,28 @@ function updateSelectionCount() {
// Update hidden input // Update hidden input
document.getElementById('selectedScenariosInput').value = JSON.stringify(Array.from(selectedScenarios)); document.getElementById('selectedScenariosInput').value = JSON.stringify(Array.from(selectedScenarios));
} }
function selectAll() { function selectAll() {
document.querySelectorAll('.scenario-checkbox').forEach(checkbox => { document.querySelectorAll('.scenario-checkbox').forEach(checkbox => {
checkbox.checked = true; checkbox.checked = true;
selectedScenarios.add(checkbox.value); selectedScenarios.add(checkbox.value);
}); });
updateSelectionCount(); updateSelectionCount();
} }
function deselectAll() { function deselectAll() {
document.querySelectorAll('.scenario-checkbox, .stack-checkbox, .layer-checkbox').forEach(checkbox => { document.querySelectorAll('.scenario-checkbox, .stack-checkbox, .layer-checkbox').forEach(checkbox => {
checkbox.checked = false; checkbox.checked = false;
checkbox.indeterminate = false; checkbox.indeterminate = false;
}); });
selectedScenarios.clear(); selectedScenarios.clear();
updateSelectionCount(); updateSelectionCount();
} }
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
updateSelectionCount(); updateSelectionCount();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -7,3 +7,4 @@ gunicorn==21.2.0
python-dotenv==1.0.0 python-dotenv==1.0.0
Werkzeug==3.0.1 Werkzeug==3.0.1
WTForms==3.1.1 WTForms==3.1.1
requests==2.31.0