init
This commit is contained in:
50
app/__init__.py
Normal file
50
app/__init__.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
import os
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///testarena.db')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
|
||||
from app.models import User
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.admin import admin_bp
|
||||
from app.routes.dashboard import dashboard_bp
|
||||
from app.routes.jobs import jobs_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(jobs_bp)
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
# Create default admin user if not exists
|
||||
try:
|
||||
if not User.query.filter_by(username='admin').first():
|
||||
admin = User(username='admin', is_admin=True)
|
||||
admin.set_password('admin123')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
# Admin user might already exist, rollback and continue
|
||||
db.session.rollback()
|
||||
|
||||
return app
|
||||
47
app/models.py
Normal file
47
app/models.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from app import db
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
jobs = db.relationship('Job', backref='submitter', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
class Job(db.Model):
|
||||
__tablename__ = 'jobs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
branch_name = db.Column(db.String(255), nullable=False)
|
||||
scenarios = db.Column(db.Text, nullable=False) # JSON string
|
||||
environment = db.Column(db.String(50), nullable=False)
|
||||
test_mode = db.Column(db.String(50), nullable=False)
|
||||
status = db.Column(db.String(20), default='in_progress') # in_progress, passed, failed, aborted
|
||||
submitted_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
duration = db.Column(db.Integer, nullable=True) # in seconds
|
||||
keep_devbenches = db.Column(db.Boolean, default=False)
|
||||
reuse_results = db.Column(db.Boolean, default=False)
|
||||
results_path = db.Column(db.String(500), nullable=True)
|
||||
|
||||
def get_status_icon(self):
|
||||
icons = {
|
||||
'in_progress': '🟠',
|
||||
'passed': '🟢',
|
||||
'failed': '🔴',
|
||||
'aborted': '⚫'
|
||||
}
|
||||
return icons.get(self.status, '⚪')
|
||||
81
app/routes/admin.py
Normal file
81
app/routes/admin.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app.models import User
|
||||
from app import db
|
||||
from functools import wraps
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
flash('Access denied. Admin privileges required.', 'error')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@admin_bp.route('/')
|
||||
@login_required
|
||||
@admin_required
|
||||
def index():
|
||||
users = User.query.order_by(User.created_at.desc()).all()
|
||||
return render_template('admin/dashboard.html', users=users)
|
||||
|
||||
@admin_bp.route('/users/create', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
is_admin = request.form.get('is_admin') == 'on'
|
||||
|
||||
if not username or not password:
|
||||
flash('Username and password are required', 'error')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists', 'error')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
user = User(username=username, is_admin=is_admin)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'User {username} created successfully', 'success')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
@admin_bp.route('/users/<int:user_id>/reset-password', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def reset_password(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
new_password = request.form.get('new_password')
|
||||
|
||||
if not new_password:
|
||||
flash('New password is required', 'error')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Password reset for {user.username}', 'success')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
@admin_bp.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if user.id == current_user.id:
|
||||
flash('Cannot delete your own account', 'error')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
username = user.username
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'User {username} deleted successfully', 'success')
|
||||
return redirect(url_for('admin.index'))
|
||||
32
app/routes/auth.py
Normal file
32
app/routes/auth.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_user, logout_user, login_required
|
||||
from app.models import User
|
||||
from app import db
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/')
|
||||
def index():
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if user and user.check_password(password):
|
||||
login_user(user)
|
||||
return redirect(url_for('dashboard.index'))
|
||||
else:
|
||||
flash('Invalid username or password', 'error')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
||||
15
app/routes/dashboard.py
Normal file
15
app/routes/dashboard.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import login_required, current_user
|
||||
from app.models import Job
|
||||
|
||||
dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/dashboard')
|
||||
|
||||
@dashboard_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
if current_user.is_admin:
|
||||
jobs = Job.query.order_by(Job.submitted_at.desc()).all()
|
||||
else:
|
||||
jobs = Job.query.filter_by(user_id=current_user.id).order_by(Job.submitted_at.desc()).all()
|
||||
|
||||
return render_template('dashboard/index.html', jobs=jobs)
|
||||
431
app/routes/jobs.py
Normal file
431
app/routes/jobs.py
Normal file
@@ -0,0 +1,431 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app.models import Job
|
||||
from app import db
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
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')
|
||||
@login_required
|
||||
def debug_test_step2():
|
||||
"""Debug endpoint to test step2 template with mock data"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
# Mock data similar to what we expect from scenario scan
|
||||
organized_data = {
|
||||
"drivers": {
|
||||
"diag_protocol_stack": ["diag_protocol_stack_init_test"]
|
||||
},
|
||||
"application_layer": {
|
||||
"data_pool": ["data_pool_init_test"],
|
||||
"actuator_manager": ["actuator_manager_init_test2", "actuator_manager_init_test"],
|
||||
"event_system": ["event_system_init_test"]
|
||||
}
|
||||
}
|
||||
|
||||
scenario_map = {
|
||||
"diag_protocol_stack_init_test": "drivers/diag_protocol_stack/test/diag_protocol_stack_init_test.test_scenario.xml",
|
||||
"data_pool_init_test": "application_layer/DP_stack/data_pool/test/data_pool_init_test.test_scenario.xml",
|
||||
"actuator_manager_init_test2": "application_layer/business_stack/actuator_manager/test/actuator_manager_init_test2.test_scenario.xml",
|
||||
"actuator_manager_init_test": "application_layer/business_stack/actuator_manager/test/actuator_manager_init_test.test_scenario.xml",
|
||||
"event_system_init_test": "application_layer/business_stack/event_system/test/event_system_init_test.test_scenario.xml"
|
||||
}
|
||||
|
||||
try:
|
||||
return render_template('jobs/submit_step2.html',
|
||||
branch_name='test_branch',
|
||||
organized_data=organized_data,
|
||||
scenario_map=scenario_map)
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': str(e),
|
||||
'type': type(e).__name__,
|
||||
'template_exists': True
|
||||
})
|
||||
|
||||
@jobs_bp.route('/debug/ssh-test')
|
||||
@login_required
|
||||
def debug_ssh_test():
|
||||
"""Debug endpoint to test SSH connectivity"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
try:
|
||||
ssh_password = os.environ.get('SSH_PASSWORD', 'default_password')
|
||||
ssh_host = os.environ.get('SSH_HOST', 'remote_host')
|
||||
ssh_user = os.environ.get('SSH_USER', 'asf')
|
||||
ssh_port = os.environ.get('SSH_PORT', '22')
|
||||
|
||||
# SSH options for better connectivity
|
||||
ssh_options = f"-p {ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
|
||||
# Test basic SSH connectivity
|
||||
test_cmd = f"sshpass -p '{ssh_password}' ssh {ssh_options} {ssh_user}@{ssh_host} 'echo SSH_CONNECTION_SUCCESS'"
|
||||
result = subprocess.run(test_cmd, shell=True, capture_output=True, text=True, timeout=30)
|
||||
|
||||
return jsonify({
|
||||
'ssh_config': {
|
||||
'user': ssh_user,
|
||||
'host': ssh_host,
|
||||
'port': ssh_port,
|
||||
'password_set': bool(ssh_password and ssh_password != 'default_password')
|
||||
},
|
||||
'command_executed': test_cmd.replace(ssh_password, '***'),
|
||||
'test_result': {
|
||||
'returncode': result.returncode,
|
||||
'stdout': result.stdout,
|
||||
'stderr': result.stderr
|
||||
},
|
||||
'success': result.returncode == 0 and 'SSH_CONNECTION_SUCCESS' in result.stdout
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': str(e),
|
||||
'success': False
|
||||
})
|
||||
|
||||
@jobs_bp.route('/submit/step1', methods=['POST'])
|
||||
@login_required
|
||||
def submit_step1():
|
||||
branch_name = request.form.get('branch_name')
|
||||
|
||||
if not branch_name:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Branch name is required',
|
||||
'output': ''
|
||||
})
|
||||
|
||||
# Validate branch exists on remote
|
||||
try:
|
||||
# Get SSH configuration from environment variables
|
||||
ssh_password = os.environ.get('SSH_PASSWORD', 'default_password')
|
||||
ssh_host = os.environ.get('SSH_HOST', 'remote_host')
|
||||
ssh_user = os.environ.get('SSH_USER', 'asf')
|
||||
ssh_port = os.environ.get('SSH_PORT', '22')
|
||||
|
||||
print(f"[DEBUG] Starting branch validation for: {branch_name}")
|
||||
print(f"[DEBUG] SSH Config - User: {ssh_user}, Host: {ssh_host}, Port: {ssh_port}")
|
||||
|
||||
# SSH options for better connectivity
|
||||
ssh_options = f"-p {ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
|
||||
# First, clone the repository
|
||||
clone_cmd = f"sshpass -p '{ssh_password}' ssh {ssh_options} {ssh_user}@{ssh_host} './TPF/gitea_repo_controller.sh clone'"
|
||||
print(f"[DEBUG] Executing clone command: {clone_cmd.replace(ssh_password, '***')}")
|
||||
|
||||
clone_result = subprocess.run(clone_cmd, shell=True, capture_output=True, text=True, timeout=60)
|
||||
|
||||
print(f"[DEBUG] Clone result - Return code: {clone_result.returncode}")
|
||||
print(f"[DEBUG] Clone stdout: {clone_result.stdout}")
|
||||
print(f"[DEBUG] Clone stderr: {clone_result.stderr}")
|
||||
|
||||
# Then, checkout the branch
|
||||
checkout_cmd = f"sshpass -p '{ssh_password}' ssh {ssh_options} {ssh_user}@{ssh_host} './TPF/gitea_repo_controller.sh checkout {branch_name}'"
|
||||
print(f"[DEBUG] Executing checkout command: {checkout_cmd.replace(ssh_password, '***')}")
|
||||
|
||||
checkout_result = subprocess.run(checkout_cmd, shell=True, capture_output=True, text=True, timeout=60)
|
||||
|
||||
print(f"[DEBUG] Checkout result - Return code: {checkout_result.returncode}")
|
||||
print(f"[DEBUG] Checkout stdout: {checkout_result.stdout}")
|
||||
print(f"[DEBUG] Checkout stderr: {checkout_result.stderr}")
|
||||
|
||||
# Combine all output for analysis
|
||||
full_output = f"CLONE OUTPUT:\n{clone_result.stdout}\n{clone_result.stderr}\n\nCHECKOUT OUTPUT:\n{checkout_result.stdout}\n{checkout_result.stderr}"
|
||||
|
||||
# Check if checkout was successful
|
||||
# Look for "fatal:" in the output which indicates failure
|
||||
checkout_failed = (
|
||||
"fatal:" in checkout_result.stdout.lower() or
|
||||
"fatal:" in checkout_result.stderr.lower() or
|
||||
checkout_result.returncode != 0
|
||||
)
|
||||
|
||||
print(f"[DEBUG] Checkout failed: {checkout_failed}")
|
||||
|
||||
if checkout_failed:
|
||||
error_msg = f'Branch "{branch_name}" not found on remote. Please push the branch first.'
|
||||
|
||||
# Try to extract more specific error from output
|
||||
if "fatal:" in checkout_result.stdout:
|
||||
fatal_line = [line for line in checkout_result.stdout.split('\n') if 'fatal:' in line.lower()]
|
||||
if fatal_line:
|
||||
error_msg += f" Error: {fatal_line[0].strip()}"
|
||||
elif "fatal:" in checkout_result.stderr:
|
||||
fatal_line = [line for line in checkout_result.stderr.split('\n') if 'fatal:' in line.lower()]
|
||||
if fatal_line:
|
||||
error_msg += f" Error: {fatal_line[0].strip()}"
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': error_msg,
|
||||
'output': full_output,
|
||||
'debug': {
|
||||
'clone_returncode': clone_result.returncode,
|
||||
'checkout_returncode': checkout_result.returncode,
|
||||
'branch_name': branch_name
|
||||
}
|
||||
})
|
||||
|
||||
# If successful, run scenario scan to get available scenarios
|
||||
print(f"[DEBUG] Running scenario scan...")
|
||||
scan_cmd = f"sshpass -p '{ssh_password}' ssh {ssh_options} {ssh_user}@{ssh_host} 'python3 TPF/Sensor_hub_repo/Tools/TPF/scenario_scan.py'"
|
||||
print(f"[DEBUG] Executing scan command: {scan_cmd.replace(ssh_password, '***')}")
|
||||
|
||||
scan_result = subprocess.run(scan_cmd, shell=True, capture_output=True, text=True, timeout=120)
|
||||
|
||||
print(f"[DEBUG] Scan result - Return code: {scan_result.returncode}")
|
||||
print(f"[DEBUG] Scan stdout: {scan_result.stdout}")
|
||||
print(f"[DEBUG] Scan stderr: {scan_result.stderr}")
|
||||
|
||||
# Parse the JSON response from scenario scan
|
||||
try:
|
||||
scan_data = json.loads(scan_result.stdout)
|
||||
organized_data = scan_data.get('organized_data', {})
|
||||
scenario_map = scan_data.get('scenario_map', {})
|
||||
|
||||
print(f"[DEBUG] Found {len(scenario_map)} scenarios in {len(organized_data)} categories")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[ERROR] Failed to parse scenario scan JSON: {e}")
|
||||
print(f"[ERROR] Raw output: {scan_result.stdout}")
|
||||
# Fallback to mock data if scan fails
|
||||
organized_data = {
|
||||
"drivers": {
|
||||
"diag_protocol_stack": ["diag_protocol_stack_init_test"]
|
||||
},
|
||||
"application_layer": {
|
||||
"data_pool": ["data_pool_init_test"],
|
||||
"actuator_manager": ["actuator_manager_init_test2", "actuator_manager_init_test"],
|
||||
"event_system": ["event_system_init_test"]
|
||||
}
|
||||
}
|
||||
scenario_map = {
|
||||
"diag_protocol_stack_init_test": "drivers/diag_protocol_stack/test/diag_protocol_stack_init_test.test_scenario.xml",
|
||||
"data_pool_init_test": "application_layer/DP_stack/data_pool/test/data_pool_init_test.test_scenario.xml",
|
||||
"actuator_manager_init_test2": "application_layer/business_stack/actuator_manager/test/actuator_manager_init_test2.test_scenario.xml",
|
||||
"actuator_manager_init_test": "application_layer/business_stack/actuator_manager/test/actuator_manager_init_test.test_scenario.xml",
|
||||
"event_system_init_test": "application_layer/business_stack/event_system/test/event_system_init_test.test_scenario.xml"
|
||||
}
|
||||
|
||||
print(f"[DEBUG] Branch validation successful for: {branch_name}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'organized_data': organized_data,
|
||||
'scenario_map': scenario_map,
|
||||
'message': f'Branch "{branch_name}" validated successfully',
|
||||
'output': full_output,
|
||||
'debug': {
|
||||
'clone_returncode': clone_result.returncode,
|
||||
'checkout_returncode': checkout_result.returncode,
|
||||
'scan_returncode': scan_result.returncode if 'scan_result' in locals() else 'N/A',
|
||||
'branch_name': branch_name,
|
||||
'scenarios_found': len(scenario_map)
|
||||
}
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = 'Branch validation timed out. Please try again.'
|
||||
print(f"[ERROR] {error_msg}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': error_msg,
|
||||
'output': 'Command timed out after 60 seconds'
|
||||
})
|
||||
except Exception as e:
|
||||
error_msg = f'Error validating branch: {str(e)}'
|
||||
print(f"[ERROR] {error_msg}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': error_msg,
|
||||
'output': f'Exception: {str(e)}'
|
||||
})
|
||||
|
||||
@jobs_bp.route('/submit/step2', methods=['POST'])
|
||||
@login_required
|
||||
def submit_step2():
|
||||
branch_name = request.form.get('branch_name')
|
||||
scenario_map_json = request.form.get('scenario_map')
|
||||
selected_scenarios_json = request.form.get('selected_scenarios')
|
||||
|
||||
try:
|
||||
scenario_map = json.loads(scenario_map_json) if scenario_map_json else {}
|
||||
selected_scenarios = json.loads(selected_scenarios_json) if selected_scenarios_json else []
|
||||
except json.JSONDecodeError:
|
||||
flash('Invalid scenario data', 'error')
|
||||
return redirect(url_for('jobs.submit'))
|
||||
|
||||
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,
|
||||
scenario_map=scenario_map)
|
||||
|
||||
@jobs_bp.route('/submit/step2-validated', methods=['POST'])
|
||||
@login_required
|
||||
def submit_step2_validated():
|
||||
try:
|
||||
branch_name = request.form.get('branch_name')
|
||||
organized_data_json = request.form.get('organized_data')
|
||||
scenario_map_json = request.form.get('scenario_map')
|
||||
|
||||
print(f"[DEBUG] Step2 - Branch: {branch_name}")
|
||||
print(f"[DEBUG] Step2 - Organized data length: {len(organized_data_json) if organized_data_json else 0}")
|
||||
print(f"[DEBUG] Step2 - Scenario map length: {len(scenario_map_json) if scenario_map_json else 0}")
|
||||
|
||||
if not branch_name:
|
||||
print("[ERROR] Step2 - No branch name provided")
|
||||
flash('Branch name is required', 'error')
|
||||
return redirect(url_for('jobs.submit'))
|
||||
|
||||
try:
|
||||
organized_data = json.loads(organized_data_json) if organized_data_json else {}
|
||||
scenario_map = json.loads(scenario_map_json) if scenario_map_json else {}
|
||||
|
||||
print(f"[DEBUG] Step2 - Parsed organized_data keys: {list(organized_data.keys())}")
|
||||
print(f"[DEBUG] Step2 - Parsed scenario_map count: {len(scenario_map)}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[ERROR] Step2 - JSON decode error: {e}")
|
||||
flash('Invalid scenario data', 'error')
|
||||
return redirect(url_for('jobs.submit'))
|
||||
|
||||
print(f"[DEBUG] Step2 - Rendering template with {len(organized_data)} layers")
|
||||
|
||||
return render_template('jobs/submit_step2.html',
|
||||
branch_name=branch_name,
|
||||
organized_data=organized_data,
|
||||
scenario_map=scenario_map)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Step2 - Unexpected error: {str(e)}")
|
||||
print(f"[ERROR] Step2 - Error type: {type(e).__name__}")
|
||||
import traceback
|
||||
print(f"[ERROR] Step2 - Traceback: {traceback.format_exc()}")
|
||||
flash(f'Error loading scenario selection: {str(e)}', 'error')
|
||||
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'])
|
||||
@login_required
|
||||
def submit_final():
|
||||
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')
|
||||
test_mode = request.form.get('test_mode')
|
||||
keep_devbenches = request.form.get('keep_devbenches') == 'on'
|
||||
reuse_results = request.form.get('reuse_results') == 'on'
|
||||
|
||||
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'))
|
||||
|
||||
job = Job(
|
||||
user_id=current_user.id,
|
||||
branch_name=branch_name,
|
||||
scenarios=json.dumps(scenarios), # Store as JSON string
|
||||
environment=environment,
|
||||
test_mode=test_mode,
|
||||
keep_devbenches=keep_devbenches,
|
||||
reuse_results=reuse_results,
|
||||
status='in_progress'
|
||||
)
|
||||
|
||||
db.session.add(job)
|
||||
db.session.commit()
|
||||
|
||||
# TODO: Start test execution in background using scenario_map
|
||||
|
||||
flash('Test job submitted successfully', 'success')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
@jobs_bp.route('/<int:job_id>')
|
||||
@login_required
|
||||
def view_job(job_id):
|
||||
job = Job.query.get_or_404(job_id)
|
||||
|
||||
if not current_user.is_admin and job.user_id != current_user.id:
|
||||
flash('Access denied', 'error')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
return jsonify({
|
||||
'id': job.id,
|
||||
'submitter': job.submitter.username,
|
||||
'branch_name': job.branch_name,
|
||||
'scenarios': job.scenarios,
|
||||
'environment': job.environment,
|
||||
'test_mode': job.test_mode,
|
||||
'status': job.status,
|
||||
'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,
|
||||
'duration': job.duration,
|
||||
'keep_devbenches': job.keep_devbenches,
|
||||
'reuse_results': job.reuse_results,
|
||||
'results_path': job.results_path
|
||||
})
|
||||
|
||||
@jobs_bp.route('/<int:job_id>/abort', methods=['POST'])
|
||||
@login_required
|
||||
def abort_job(job_id):
|
||||
job = Job.query.get_or_404(job_id)
|
||||
|
||||
if not current_user.is_admin and job.user_id != current_user.id:
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
|
||||
if job.status == 'in_progress':
|
||||
job.status = 'aborted'
|
||||
db.session.commit()
|
||||
# TODO: Kill the running process
|
||||
return jsonify({'success': True})
|
||||
|
||||
return jsonify({'error': 'Job is not in progress'}), 400
|
||||
756
app/static/css/style.css
Normal file
756
app/static/css/style.css
Normal file
@@ -0,0 +1,756 @@
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1e40af;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--dark: #1f2937;
|
||||
--light: #f3f4f6;
|
||||
--border: #e5e7eb;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Login Page */
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
max-width: 120px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
color: var(--dark);
|
||||
margin-top: 15px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Alert Messages */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
background: white;
|
||||
padding: 15px 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.navbar-brand img {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.navbar-brand h2 {
|
||||
color: var(--dark);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-menu a {
|
||||
color: var(--dark);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.navbar-menu a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Dashboard Layout */
|
||||
.dashboard-container {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
color: var(--dark);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Job List */
|
||||
.job-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.job-item {
|
||||
padding: 15px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.job-item:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--light);
|
||||
}
|
||||
|
||||
.job-item.active {
|
||||
border-color: var(--primary);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.job-status-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.job-info h4 {
|
||||
color: var(--dark);
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.job-info p {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Job Details */
|
||||
.job-details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.job-details.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-in_progress {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-passed {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-aborted {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Admin Dashboard */
|
||||
.admin-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.admin-header h2 {
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.user-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.user-table th {
|
||||
background: var(--light);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--dark);
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.user-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.user-table tr:hover {
|
||||
background: var(--light);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Submit Form */
|
||||
.submit-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
margin-top: 20px;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.step:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step.active .step-number {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step.completed .step-number {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
color: var(--dark);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-item:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.radio-item input[type="radio"]:checked + label {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* Context Menu */
|
||||
.context-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--light);
|
||||
}
|
||||
|
||||
.context-menu-item:first-child {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.context-menu-item:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.context-menu-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Branch Validation */
|
||||
.branch-validation {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.branch-validation.success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
.branch-validation.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
.branch-validation.loading {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}/
|
||||
* Scenario Tree Styles */
|
||||
.scenario-controls {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: var(--light);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scenario-tree {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
margin: 20px 0;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tree-layer {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tree-stack {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tree-scenario {
|
||||
margin-left: 40px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-spacer {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.layer-label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.stack-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.scenario-label {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.layer-checkbox, .stack-checkbox, .scenario-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-node:hover {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stack-node:hover {
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scenario-node:hover {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Indeterminate checkbox styling */
|
||||
input[type="checkbox"]:indeterminate {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
input[type="checkbox"]:indeterminate::before {
|
||||
content: '−';
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-align: center;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* Selection count styling */
|
||||
#selectionCount {
|
||||
color: var(--primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
BIN
app/static/uploads/icon.png
Normal file
BIN
app/static/uploads/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
116
app/templates/admin/dashboard.html
Normal file
116
app/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - ASF TestArena{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h2>User Management</h2>
|
||||
<button class="btn btn-primary" onclick="openModal('createUserModal')">+ Create User</button>
|
||||
</div>
|
||||
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created At</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge badge-admin">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge badge-user">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="openResetPasswordModal({{ user.id }}, '{{ user.username }}')">Reset Password</button>
|
||||
{% if user.id != current_user.id %}
|
||||
<form method="POST" action="{{ url_for('admin.delete_user', user_id=user.id) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Delete user {{ user.username }}?')">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div id="createUserModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Create New User</h3>
|
||||
<button class="close-btn" onclick="closeModal('createUserModal')">×</button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin.create_user') }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin">
|
||||
Admin User
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Modal -->
|
||||
<div id="resetPasswordModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Reset Password</h3>
|
||||
<button class="close-btn" onclick="closeModal('resetPasswordModal')">×</button>
|
||||
</div>
|
||||
<form id="resetPasswordForm" method="POST">
|
||||
<p>Reset password for: <strong id="resetUsername"></strong></p>
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password</label>
|
||||
<input type="password" id="new_password" name="new_password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Reset Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('active');
|
||||
}
|
||||
|
||||
function openResetPasswordModal(userId, username) {
|
||||
document.getElementById('resetUsername').textContent = username;
|
||||
document.getElementById('resetPasswordForm').action = `/admin/users/${userId}/reset-password`;
|
||||
openModal('resetPasswordModal');
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.onclick = function(event) {
|
||||
if (event.target.classList.contains('modal')) {
|
||||
event.target.classList.remove('active');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
42
app/templates/base.html
Normal file
42
app/templates/base.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}ASF TestArena{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="navbar">
|
||||
<div class="navbar-brand">
|
||||
<img src="{{ url_for('static', filename='uploads/icon.png') }}" alt="Logo">
|
||||
<h2>ASF TestArena</h2>
|
||||
</div>
|
||||
<div class="navbar-menu">
|
||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
<a href="{{ url_for('jobs.submit') }}">Submit Job</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.index') }}">Admin</a>
|
||||
{% endif %}
|
||||
<span>{{ current_user.username }}</span>
|
||||
<a href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
176
app/templates/dashboard/index.html
Normal file
176
app/templates/dashboard/index.html
Normal file
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - ASF TestArena{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-container">
|
||||
<div class="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>
|
||||
|
||||
<div class="job-list">
|
||||
{% if 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-status-icon">{{ job.get_status_icon() }}</div>
|
||||
<div class="job-info">
|
||||
<h4>Job #{{ job.id }} - {{ job.branch_name }}</h4>
|
||||
<p>{{ job.submitted_at.strftime('%Y-%m-%d %H:%M') }} by {{ job.submitter.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No jobs yet</h3>
|
||||
<p>Submit your first test job to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Job Details</h3>
|
||||
</div>
|
||||
|
||||
<div id="job-details-container">
|
||||
<div class="empty-state">
|
||||
<h3>Select a job</h3>
|
||||
<p>Click on a job from the list to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="contextMenu" class="context-menu">
|
||||
<div class="context-menu-item" onclick="abortJobFromContext()">
|
||||
<span class="context-menu-icon">⚫</span>
|
||||
Abort Job
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let contextJobId = null;
|
||||
|
||||
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');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
document.getElementById('contextMenu').style.display = 'none';
|
||||
document.removeEventListener('click', hideContextMenu);
|
||||
contextJobId = null;
|
||||
}
|
||||
|
||||
function abortJobFromContext() {
|
||||
if (contextJobId) {
|
||||
abortJob(contextJobId);
|
||||
}
|
||||
hideContextMenu();
|
||||
}
|
||||
function loadJobDetails(jobId) {
|
||||
// Mark job as active
|
||||
document.querySelectorAll('.job-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-job-id="${jobId}"]`).classList.add('active');
|
||||
|
||||
// Fetch job details
|
||||
fetch(`/jobs/${jobId}`)
|
||||
.then(response => response.json())
|
||||
.then(job => {
|
||||
const container = document.getElementById('job-details-container');
|
||||
const scenarios = JSON.parse(job.scenarios || '[]');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Job ID:</div>
|
||||
<div class="detail-value">#${job.id}</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">
|
||||
<span class="status-badge status-${job.status}">${job.status.replace('_', ' ').toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Environment:</div>
|
||||
<div class="detail-value">${job.environment}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Test Mode:</div>
|
||||
<div class="detail-value">${job.test_mode}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Submitted:</div>
|
||||
<div class="detail-value">${job.submitted_at}</div>
|
||||
</div>
|
||||
${job.completed_at ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Completed:</div>
|
||||
<div class="detail-value">${job.completed_at}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Duration:</div>
|
||||
<div class="detail-value">${job.duration ? Math.floor(job.duration / 60) + 'm ' + (job.duration % 60) + 's' : 'N/A'}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Scenarios:</div>
|
||||
<div class="detail-value">${Array.isArray(scenarios) ? scenarios.join(', ') : scenarios}</div>
|
||||
</div>
|
||||
${job.status === 'in_progress' ? `
|
||||
<div style="margin-top: 20px;">
|
||||
<button class="btn btn-danger" onclick="abortJob(${job.id})">Abort Job</button>
|
||||
</div>
|
||||
` : ''}
|
||||
${job.results_path ? `
|
||||
<div style="margin-top: 20px;">
|
||||
<a href="${job.results_path}" class="btn btn-primary" target="_blank">View Results</a>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function abortJob(jobId) {
|
||||
if (confirm('Are you sure you want to abort this job?')) {
|
||||
fetch(`/jobs/${jobId}/abort`, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
197
app/templates/jobs/submit.html
Normal file
197
app/templates/jobs/submit.html
Normal file
@@ -0,0 +1,197 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Submit Test Job - ASF TestArena{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="submit-container">
|
||||
<h2 style="margin-bottom: 30px; color: var(--dark);">Submit New Test Job</h2>
|
||||
|
||||
<div class="step-indicator">
|
||||
<div class="step active">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-label">Branch</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-label">Scenarios</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form id="branchForm">
|
||||
<div class="form-group">
|
||||
<label for="branch_name">Git Branch Name</label>
|
||||
<input type="text" id="branch_name" name="branch_name" class="form-control"
|
||||
placeholder="e.g., feature/new-feature" required>
|
||||
<small style="color: #6b7280; margin-top: 5px; display: block;">
|
||||
Enter the branch name to analyze available test scenarios
|
||||
</small>
|
||||
|
||||
<div id="branchValidation" class="branch-validation">
|
||||
<div class="loading-spinner"></div>
|
||||
<span id="validationMessage">Validating branch...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<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="button" id="nextBtn" class="btn btn-primary" style="display: none;" onclick="proceedToStep2()">Next</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let validatedBranch = null;
|
||||
let organizedData = {};
|
||||
let scenarioMap = {};
|
||||
|
||||
document.getElementById('branchForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
validateBranch();
|
||||
});
|
||||
|
||||
function validateBranch() {
|
||||
const branchName = document.getElementById('branch_name').value.trim();
|
||||
|
||||
if (!branchName) {
|
||||
alert('Please enter a branch name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const validation = document.getElementById('branchValidation');
|
||||
const message = document.getElementById('validationMessage');
|
||||
const validateBtn = document.getElementById('validateBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
|
||||
validation.className = 'branch-validation loading';
|
||||
validation.style.display = 'block';
|
||||
message.textContent = 'Validating branch...';
|
||||
validateBtn.disabled = true;
|
||||
nextBtn.style.display = 'none';
|
||||
|
||||
// Make AJAX request
|
||||
fetch('/jobs/submit/step1', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `branch_name=${encodeURIComponent(branchName)}`
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Branch validation response:', data); // Debug log
|
||||
|
||||
if (data.success) {
|
||||
// Success
|
||||
validation.className = 'branch-validation success';
|
||||
message.textContent = data.message;
|
||||
validateBtn.style.display = 'none';
|
||||
nextBtn.style.display = 'inline-block';
|
||||
|
||||
validatedBranch = branchName;
|
||||
organizedData = data.organized_data || {};
|
||||
scenarioMap = data.scenario_map || {};
|
||||
|
||||
// Log debug info
|
||||
if (data.debug) {
|
||||
console.log('Debug info:', data.debug);
|
||||
}
|
||||
if (data.output) {
|
||||
console.log('Command output:', data.output);
|
||||
}
|
||||
} else {
|
||||
// Error
|
||||
validation.className = 'branch-validation error';
|
||||
message.textContent = data.error;
|
||||
validateBtn.disabled = false;
|
||||
nextBtn.style.display = 'none';
|
||||
|
||||
validatedBranch = null;
|
||||
organizedData = {};
|
||||
scenarioMap = {};
|
||||
|
||||
// Log debug info for troubleshooting
|
||||
console.error('Branch validation failed:', data.error);
|
||||
if (data.debug) {
|
||||
console.error('Debug info:', data.debug);
|
||||
}
|
||||
if (data.output) {
|
||||
console.error('Command output:', data.output);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Network error
|
||||
validation.className = 'branch-validation error';
|
||||
message.textContent = 'Network error. Please try again.';
|
||||
validateBtn.disabled = false;
|
||||
nextBtn.style.display = 'none';
|
||||
|
||||
validatedBranch = null;
|
||||
availableScenarios = [];
|
||||
});
|
||||
}
|
||||
|
||||
function proceedToStep2() {
|
||||
if (!validatedBranch || Object.keys(organizedData).length === 0) {
|
||||
alert('Please validate the branch first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create form to submit to step 2
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/jobs/submit/step2-validated';
|
||||
|
||||
const branchInput = document.createElement('input');
|
||||
branchInput.type = 'hidden';
|
||||
branchInput.name = 'branch_name';
|
||||
branchInput.value = validatedBranch;
|
||||
form.appendChild(branchInput);
|
||||
|
||||
const organizedDataInput = document.createElement('input');
|
||||
organizedDataInput.type = 'hidden';
|
||||
organizedDataInput.name = 'organized_data';
|
||||
organizedDataInput.value = JSON.stringify(organizedData);
|
||||
form.appendChild(organizedDataInput);
|
||||
|
||||
const scenarioMapInput = document.createElement('input');
|
||||
scenarioMapInput.type = 'hidden';
|
||||
scenarioMapInput.name = 'scenario_map';
|
||||
scenarioMapInput.value = JSON.stringify(scenarioMap);
|
||||
form.appendChild(scenarioMapInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
// Reset validation when branch name changes
|
||||
document.getElementById('branch_name').addEventListener('input', function() {
|
||||
const validation = document.getElementById('branchValidation');
|
||||
const validateBtn = document.getElementById('validateBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
|
||||
validation.style.display = 'none';
|
||||
validateBtn.style.display = 'inline-block';
|
||||
validateBtn.disabled = false;
|
||||
nextBtn.style.display = 'none';
|
||||
|
||||
validatedBranch = null;
|
||||
organizedData = {};
|
||||
scenarioMap = {};
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
268
app/templates/jobs/submit_step2.html
Normal file
268
app/templates/jobs/submit_step2.html
Normal file
@@ -0,0 +1,268 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Select Scenarios - ASF TestArena{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="submit-container">
|
||||
<h2 style="margin-bottom: 30px; color: var(--dark);">Select Test Scenarios</h2>
|
||||
|
||||
<div class="step-indicator">
|
||||
<div class="step completed">
|
||||
<div class="step-number">✓</div>
|
||||
<div class="step-label">Branch</div>
|
||||
</div>
|
||||
<div class="step active">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-label">Scenarios</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div style="background: #eff6ff; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
|
||||
<strong>Branch:</strong> {{ branch_name }}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('jobs.submit_step2') }}" id="scenarioForm">
|
||||
<input type="hidden" name="branch_name" value="{{ branch_name }}">
|
||||
<input type="hidden" name="scenario_map" value="{{ scenario_map|tojson }}">
|
||||
<input type="hidden" name="selected_scenarios" id="selectedScenariosInput">
|
||||
|
||||
<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="deselectAll()" style="background: var(--danger); color: white;">Deselect All</button>
|
||||
<span id="selectionCount" style="margin-left: 20px; font-weight: 600;">0 scenarios selected</span>
|
||||
</div>
|
||||
|
||||
<div class="scenario-tree">
|
||||
{% if organized_data %}
|
||||
{% for layer_name, layer_data in organized_data.items() %}
|
||||
<div class="tree-layer">
|
||||
<div class="tree-node layer-node" onclick="toggleLayer('{{ layer_name }}')">
|
||||
<span class="tree-toggle" id="toggle-{{ layer_name }}">▶</span>
|
||||
<input type="checkbox" class="layer-checkbox" id="layer-{{ layer_name }}" onchange="toggleLayerSelection('{{ layer_name }}')">
|
||||
<label for="layer-{{ layer_name }}" class="tree-label layer-label">{{ layer_name.replace('_', ' ').title() }}</label>
|
||||
</div>
|
||||
|
||||
<div class="tree-children" id="children-{{ layer_name }}" style="display: none;">
|
||||
{% if layer_data %}
|
||||
{% for stack_name, scenarios in layer_data.items() %}
|
||||
<div class="tree-stack">
|
||||
<div class="tree-node stack-node" onclick="toggleStack('{{ layer_name }}', '{{ stack_name }}')">
|
||||
<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 }}')">
|
||||
<label for="stack-{{ layer_name }}-{{ stack_name }}" class="tree-label stack-label">{{ stack_name.replace('_', ' ').title() }}</label>
|
||||
</div>
|
||||
|
||||
<div class="tree-children" id="children-{{ layer_name }}-{{ stack_name }}" style="display: none;">
|
||||
{% if scenarios %}
|
||||
{% for scenario in scenarios %}
|
||||
<div class="tree-scenario">
|
||||
<div class="tree-node scenario-node">
|
||||
<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 }}">
|
||||
<label for="scenario-{{ scenario }}" class="tree-label scenario-label">{{ scenario }}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="tree-scenario">
|
||||
<div class="tree-node scenario-node">
|
||||
<span class="tree-spacer"></span>
|
||||
<span class="tree-label scenario-label" style="color: #9ca3af;">No scenarios found</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="tree-stack">
|
||||
<div class="tree-node stack-node">
|
||||
<span class="tree-spacer"></span>
|
||||
<span class="tree-label stack-label" style="color: #9ca3af;">No stacks found</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="tree-layer">
|
||||
<div class="tree-node layer-node">
|
||||
<span class="tree-label layer-label" style="color: #9ca3af;">No scenarios available</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('jobs.submit') }}" class="btn" style="background: #6b7280; color: white;">Back</a>
|
||||
<button type="submit" class="btn btn-primary" id="nextBtn" disabled>Next</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedScenarios = new Set();
|
||||
|
||||
function toggleLayer(layerName) {
|
||||
const children = document.getElementById(`children-${layerName}`);
|
||||
const toggle = document.getElementById(`toggle-${layerName}`);
|
||||
|
||||
if (children.style.display === 'none') {
|
||||
children.style.display = 'block';
|
||||
toggle.textContent = '▼';
|
||||
} else {
|
||||
children.style.display = 'none';
|
||||
toggle.textContent = '▶';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStack(layerName, stackName) {
|
||||
const children = document.getElementById(`children-${layerName}-${stackName}`);
|
||||
const toggle = document.getElementById(`toggle-${layerName}-${stackName}`);
|
||||
|
||||
if (children.style.display === 'none') {
|
||||
children.style.display = 'block';
|
||||
toggle.textContent = '▼';
|
||||
} else {
|
||||
children.style.display = 'none';
|
||||
toggle.textContent = '▶';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLayerSelection(layerName) {
|
||||
const layerCheckbox = document.getElementById(`layer-${layerName}`);
|
||||
const stackCheckboxes = document.querySelectorAll(`input[id^="stack-${layerName}-"]`);
|
||||
const scenarioCheckboxes = document.querySelectorAll(`input[data-layer="${layerName}"]`);
|
||||
|
||||
// Update all stacks and scenarios in this layer
|
||||
stackCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = layerCheckbox.checked;
|
||||
checkbox.indeterminate = false;
|
||||
});
|
||||
|
||||
scenarioCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = layerCheckbox.checked;
|
||||
if (layerCheckbox.checked) {
|
||||
selectedScenarios.add(checkbox.value);
|
||||
} else {
|
||||
selectedScenarios.delete(checkbox.value);
|
||||
}
|
||||
});
|
||||
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function toggleStackSelection(layerName, stackName) {
|
||||
const stackCheckbox = document.getElementById(`stack-${layerName}-${stackName}`);
|
||||
const scenarioCheckboxes = document.querySelectorAll(`input[data-layer="${layerName}"][data-stack="${stackName}"]`);
|
||||
|
||||
// Update all scenarios in this stack
|
||||
scenarioCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = stackCheckbox.checked;
|
||||
if (stackCheckbox.checked) {
|
||||
selectedScenarios.add(checkbox.value);
|
||||
} else {
|
||||
selectedScenarios.delete(checkbox.value);
|
||||
}
|
||||
});
|
||||
|
||||
updateLayerState(layerName);
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function updateLayerState(layerName) {
|
||||
const layerCheckbox = document.getElementById(`layer-${layerName}`);
|
||||
const stackCheckboxes = document.querySelectorAll(`input[id^="stack-${layerName}-"]`);
|
||||
const scenarioCheckboxes = document.querySelectorAll(`input[data-layer="${layerName}"]`);
|
||||
|
||||
// Update stack states
|
||||
stackCheckboxes.forEach(stackCheckbox => {
|
||||
const stackName = stackCheckbox.id.replace(`stack-${layerName}-`, '');
|
||||
const stackScenarios = document.querySelectorAll(`input[data-layer="${layerName}"][data-stack="${stackName}"]`);
|
||||
const checkedCount = Array.from(stackScenarios).filter(cb => cb.checked).length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
stackCheckbox.checked = false;
|
||||
stackCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === stackScenarios.length) {
|
||||
stackCheckbox.checked = true;
|
||||
stackCheckbox.indeterminate = false;
|
||||
} else {
|
||||
stackCheckbox.checked = false;
|
||||
stackCheckbox.indeterminate = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Update layer state
|
||||
const checkedScenarios = Array.from(scenarioCheckboxes).filter(cb => cb.checked).length;
|
||||
if (checkedScenarios === 0) {
|
||||
layerCheckbox.checked = false;
|
||||
layerCheckbox.indeterminate = false;
|
||||
} else if (checkedScenarios === scenarioCheckboxes.length) {
|
||||
layerCheckbox.checked = true;
|
||||
layerCheckbox.indeterminate = false;
|
||||
} else {
|
||||
layerCheckbox.checked = false;
|
||||
layerCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectionCount() {
|
||||
// Update selected scenarios set
|
||||
selectedScenarios.clear();
|
||||
document.querySelectorAll('.scenario-checkbox:checked').forEach(checkbox => {
|
||||
selectedScenarios.add(checkbox.value);
|
||||
});
|
||||
|
||||
// Update all layer states
|
||||
const layers = new Set();
|
||||
document.querySelectorAll('.scenario-checkbox').forEach(checkbox => {
|
||||
layers.add(checkbox.dataset.layer);
|
||||
});
|
||||
layers.forEach(layer => updateLayerState(layer));
|
||||
|
||||
// Update UI
|
||||
const count = selectedScenarios.size;
|
||||
document.getElementById('selectionCount').textContent = `${count} scenario${count !== 1 ? 's' : ''} selected`;
|
||||
document.getElementById('nextBtn').disabled = count === 0;
|
||||
|
||||
// Update hidden input
|
||||
document.getElementById('selectedScenariosInput').value = JSON.stringify(Array.from(selectedScenarios));
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
document.querySelectorAll('.scenario-checkbox').forEach(checkbox => {
|
||||
checkbox.checked = true;
|
||||
selectedScenarios.add(checkbox.value);
|
||||
});
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
document.querySelectorAll('.scenario-checkbox, .stack-checkbox, .layer-checkbox').forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
checkbox.indeterminate = false;
|
||||
});
|
||||
selectedScenarios.clear();
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateSelectionCount();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
74
app/templates/jobs/submit_step3.html
Normal file
74
app/templates/jobs/submit_step3.html
Normal file
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Select Environment - ASF TestArena{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="submit-container">
|
||||
<h2 style="margin-bottom: 30px; color: var(--dark);">Select Environment</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">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>
|
||||
</div>
|
||||
|
||||
<div style="background: #eff6ff; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
|
||||
<strong>Branch:</strong> {{ branch_name }}<br>
|
||||
<strong>Selected Scenarios:</strong> {{ scenarios|length }} scenario{{ 's' if scenarios|length != 1 else '' }}
|
||||
<details style="margin-top: 10px;">
|
||||
<summary style="cursor: pointer; color: var(--primary);">View selected scenarios</summary>
|
||||
<ul style="margin-top: 10px; padding-left: 20px;">
|
||||
{% for scenario in scenarios %}
|
||||
<li style="font-size: 12px; color: #6b7280;">{{ scenario }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('jobs.submit_step3') }}">
|
||||
<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 }}">
|
||||
|
||||
<div class="radio-group">
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="environment" value="sensor_hub" required>
|
||||
<div>
|
||||
<strong>Sensor Hub</strong>
|
||||
<p style="font-size: 12px; color: #6b7280; margin-top: 4px;">Test on sensor hub hardware/simulator</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="environment" value="main_board" required>
|
||||
<div>
|
||||
<strong>Main Board</strong>
|
||||
<p style="font-size: 12px; color: #6b7280; margin-top: 4px;">Test on main board hardware/simulator</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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-primary">Next</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
80
app/templates/jobs/submit_step4.html
Normal file
80
app/templates/jobs/submit_step4.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Select Test Mode - ASF TestArena{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="submit-container">
|
||||
<h2 style="margin-bottom: 30px; color: var(--dark);">Select Test Mode</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 completed">
|
||||
<div class="step-number">✓</div>
|
||||
<div class="step-label">Environment</div>
|
||||
</div>
|
||||
<div class="step active">
|
||||
<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>
|
||||
</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 }}">
|
||||
<input type="hidden" name="environment" value="{{ environment }}">
|
||||
|
||||
<div class="radio-group">
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="test_mode" value="devbench_simulator" required>
|
||||
<div>
|
||||
<strong>Devbench Simulator</strong>
|
||||
<p style="font-size: 12px; color: #6b7280; margin-top: 4px;">Fully simulated environment</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="test_mode" value="testbench_hil" required>
|
||||
<div>
|
||||
<strong>Testbench HIL</strong>
|
||||
<p style="font-size: 12px; color: #6b7280; margin-top: 4px;">Hardware-in-the-Loop with real devices</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 30px;">
|
||||
<h3 style="margin-bottom: 15px; color: var(--dark);">Additional Options</h3>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label>
|
||||
<input type="checkbox" name="keep_devbenches">
|
||||
Keep devbenches after test completion
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" name="reuse_results">
|
||||
Reuse previous test results if available
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">Start Test</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
41
app/templates/login.html
Normal file
41
app/templates/login.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - ASF TestArena</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="logo">
|
||||
<img src="{{ url_for('static', filename='uploads/icon.png') }}" alt="Logo">
|
||||
<h1>ASF TestArena</h1>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user