init app of test arena

This commit is contained in:
2025-11-24 02:14:25 +01:00
parent d778206940
commit 4df7501aba
41 changed files with 5542 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
testarena.nabd-co.com {
# API and Auth
handle /api/* {
reverse_proxy backend:8000
}
handle /auth/* {
reverse_proxy backend:8000
}
handle /admin/* {
reverse_proxy backend:8000
}
handle /jobs/* {
reverse_proxy backend:8000
}
# WebSocket
handle /ws/* {
reverse_proxy backend:8000
}
# Results (Mounted volume)
handle /results/* {
root * /srv
file_server
}
# Frontend
handle {
reverse_proxy frontend:80
}
}

View File

@@ -0,0 +1,54 @@
# ASF TestArena
A full-stack web application to manage automated software test jobs.
## Features
- **Login System**: Admin and User roles.
- **Dashboard**: Real-time job monitoring with WebSocket.
- **Job Submission**: Wizard to submit jobs based on git branches.
- **Results**: HTML reports generated and served automatically.
- **Cleanup**: Automatic deletion of results older than 7 days.
## Architecture
- **Backend**: FastAPI (Python)
- **Frontend**: React + Vite (TypeScript)
- **Database**: PostgreSQL
- **Reverse Proxy**: Caddy
- **Containerization**: Docker Compose
## Deployment Instructions
### Prerequisites
- Docker and Docker Compose installed on the server.
- Domain `testarena.nabd-co.com` pointing to the server IP.
### Steps
1. **Clone the repository** to your VPS.
```bash
git clone <repo_url>
cd testarena
```
2. **Configure Environment**
- Edit `docker-compose.yml` if you need to change database passwords.
- Edit `backend/app/auth.py` to change the `SECRET_KEY`.
3. **Run with Docker Compose**
```bash
docker-compose up -d --build
```
4. **Verify**
- Open `https://testarena.nabd-co.com` in your browser.
- Login with default credentials:
- Username: `admin`
- Password: `admin123`
### Scripts Integration
- The mock scripts are located in `backend/scripts/`.
- Replace `get_scenarios.sh` and `run_tests.sh` with your actual implementation.
- Ensure the scripts are executable (`chmod +x`).
### Troubleshooting
- Check logs: `docker-compose logs -f`
- Restart services: `docker-compose restart`

View File

@@ -0,0 +1,13 @@
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Make scripts executable
RUN chmod +x scripts/*.sh
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,59 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi.security import OAuth2PasswordBearer
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from . import crud, models, schemas
from .dependencies import get_db
SECRET_KEY = "YOUR_SECRET_KEY_HERE_PLEASE_CHANGE_IN_PROD"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = schemas.TokenData(username=username)
except JWTError:
raise credentials_exception
user = crud.get_user_by_username(db, username=token_data.username)
if user is None:
raise credentials_exception
return user
def get_current_active_user(current_user: models.User = Depends(get_current_user)):
return current_user
def get_current_admin_user(current_user: models.User = Depends(get_current_user)):
if current_user.role != models.UserRole.admin:
raise HTTPException(status_code=400, detail="Not enough permissions")
return current_user

View File

@@ -0,0 +1,52 @@
from sqlalchemy.orm import Session
from . import models, schemas
def get_user(db: Session, user_id: int):
return db.query(models.User).filter(models.User.id == user_id).first()
def get_user_by_username(db: Session, username: str):
return db.query(models.User).filter(models.User.username == username).first()
def get_users(db: Session, skip: int = 0, limit: int = 100):
return db.query(models.User).offset(skip).limit(limit).all()
def create_user(db: Session, user: schemas.UserCreate, hashed_password: str):
db_user = models.User(username=user.username, hashed_password=hashed_password, role=user.role)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def delete_user(db: Session, user_id: int):
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if db_user:
db.delete(db_user)
db.commit()
return db_user
def get_jobs(db: Session, skip: int = 0, limit: int = 100, user_id: int = None):
if user_id:
return db.query(models.Job).filter(models.Job.user_id == user_id).order_by(models.Job.created_at.desc()).offset(skip).limit(limit).all()
return db.query(models.Job).order_by(models.Job.created_at.desc()).offset(skip).limit(limit).all()
def get_job(db: Session, job_id: int):
return db.query(models.Job).filter(models.Job.id == job_id).first()
def create_job(db: Session, job: schemas.JobCreate, user_id: int):
db_job = models.Job(**job.dict(), user_id=user_id)
db.add(db_job)
db.commit()
db.refresh(db_job)
return db_job
def update_job_status(db: Session, job_id: int, status: str, result_path: str = None, duration: str = None):
job = db.query(models.Job).filter(models.Job.id == job_id).first()
if job:
job.status = status
if result_path:
job.result_path = result_path
if duration:
job.duration = duration
db.commit()
db.refresh(job)
return job

View File

@@ -0,0 +1,11 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@db/testarena")
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

View File

@@ -0,0 +1,8 @@
from .database import SessionLocal
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,165 @@
from fastapi import FastAPI, Depends, HTTPException, status, WebSocket, WebSocketDisconnect, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from typing import List
from . import models, schemas, crud, auth, database, tasks
from .socket_manager import manager
from .dependencies import get_db
models.Base.metadata.create_all(bind=database.engine)
app = FastAPI(title="ASF TestArena")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, set to specific domain
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/auth/login", response_model=schemas.Token)
def login_for_access_token(form_data: schemas.UserCreate, db: Session = Depends(get_db)):
user = crud.get_user_by_username(db, form_data.username)
if not user or not auth.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = auth.timedelta(minutes=auth.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@app.post("/auth/reset-password")
def reset_password(
username: str,
new_password: str,
current_user: models.User = Depends(auth.get_current_admin_user),
db: Session = Depends(get_db)
):
user = crud.get_user_by_username(db, username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.hashed_password = auth.get_password_hash(new_password)
db.commit()
return {"message": "Password reset successfully"}
@app.get("/admin/users", response_model=List[schemas.User])
def read_users(
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(auth.get_current_admin_user),
db: Session = Depends(get_db)
):
users = crud.get_users(db, skip=skip, limit=limit)
return users
@app.post("/admin/users", response_model=schemas.User)
def create_user(
user: schemas.UserCreate,
current_user: models.User = Depends(auth.get_current_admin_user),
db: Session = Depends(get_db)
):
db_user = crud.get_user_by_username(db, username=user.username)
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
hashed_password = auth.get_password_hash(user.password)
return crud.create_user(db=db, user=user, hashed_password=hashed_password)
@app.delete("/admin/users/{user_id}")
def delete_user(
user_id: int,
current_user: models.User = Depends(auth.get_current_admin_user),
db: Session = Depends(get_db)
):
crud.delete_user(db, user_id)
return {"message": "User deleted"}
@app.get("/jobs", response_model=List[schemas.Job])
def read_jobs(
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(auth.get_current_active_user),
db: Session = Depends(get_db)
):
if current_user.role == models.UserRole.admin:
jobs = crud.get_jobs(db, skip=skip, limit=limit)
else:
jobs = crud.get_jobs(db, skip=skip, limit=limit, user_id=current_user.id)
return jobs
@app.get("/jobs/{job_id}", response_model=schemas.Job)
def read_job(
job_id: int,
current_user: models.User = Depends(auth.get_current_active_user),
db: Session = Depends(get_db)
):
job = crud.get_job(db, job_id=job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
if current_user.role != models.UserRole.admin and job.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to view this job")
return job
@app.post("/jobs/submit", response_model=schemas.Job)
def submit_job(
job: schemas.JobCreate,
background_tasks: BackgroundTasks,
current_user: models.User = Depends(auth.get_current_active_user),
db: Session = Depends(get_db)
):
db_job = crud.create_job(db=db, job=job, user_id=current_user.id)
background_tasks.add_task(tasks.run_job_task, db_job.id)
return db_job
@app.post("/jobs/{job_id}/abort")
def abort_job(
job_id: int,
current_user: models.User = Depends(auth.get_current_active_user),
db: Session = Depends(get_db)
):
job = crud.get_job(db, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if current_user.role != models.UserRole.admin and job.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
crud.update_job_status(db, job_id, "aborted")
return {"message": "Job aborted"}
@app.post("/jobs/scenarios")
def get_scenarios_endpoint(
branch_name: str,
current_user: models.User = Depends(auth.get_current_active_user)
):
scenarios = tasks.get_scenarios(branch_name)
return scenarios
@app.websocket("/ws/jobs")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
import asyncio
@app.on_event("startup")
async def startup_event():
# Start cleanup task
asyncio.create_task(tasks.cleanup_old_results())
db = database.SessionLocal()
try:
user = crud.get_user_by_username(db, "admin")
if not user:
hashed_password = auth.get_password_hash("admin123")
user_in = schemas.UserCreate(username="admin", password="admin123", role=models.UserRole.admin)
crud.create_user(db, user_in, hashed_password)
finally:
db.close()

View File

@@ -0,0 +1,41 @@
from sqlalchemy import Column, Integer, String, Enum, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from .database import Base
import datetime
import enum
class UserRole(str, enum.Enum):
admin = "admin"
user = "user"
class JobStatus(str, enum.Enum):
pending = "pending"
running = "running"
passed = "passed"
failed = "failed"
aborted = "aborted"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
hashed_password = Column(String)
role = Column(Enum(UserRole), default=UserRole.user)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
jobs = relationship("Job", back_populates="owner")
class Job(Base):
__tablename__ = "jobs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
branch_name = Column(String)
scenarios = Column(JSON)
environment = Column(String)
test_mode = Column(String)
status = Column(Enum(JobStatus), default=JobStatus.pending)
result_path = Column(String, nullable=True)
duration = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
owner = relationship("User", back_populates="jobs")

View File

@@ -0,0 +1,45 @@
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from .models import UserRole, JobStatus
class UserBase(BaseModel):
username: str
class UserCreate(UserBase):
password: str
role: UserRole = UserRole.user
class User(UserBase):
id: int
role: UserRole
created_at: datetime
class Config:
orm_mode = True
class JobBase(BaseModel):
branch_name: str
scenarios: List[str]
environment: str
test_mode: str
class JobCreate(JobBase):
pass
class Job(JobBase):
id: int
user_id: int
status: JobStatus
result_path: Optional[str]
duration: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None

View File

@@ -0,0 +1,22 @@
from fastapi import WebSocket
from typing import List
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def broadcast(self, message: str):
for connection in self.active_connections:
try:
await connection.send_text(message)
except:
self.disconnect(connection)
manager = ConnectionManager()

View File

@@ -0,0 +1,109 @@
import subprocess
import asyncio
from sqlalchemy.orm import Session
from . import crud, models, database
from .socket_manager import manager
import json
import os
import time
import shutil
# In Docker, scripts are in /app/scripts
# But for local testing, we might need a relative path or env var.
# We'll stick to the Docker path assumption or use relative.
SCRIPTS_DIR = os.getenv("SCRIPTS_DIR", "scripts")
def get_scenarios(branch_name: str):
try:
# In a real scenario, we might need to git checkout first.
# For now, just run the script.
# Ensure we are running from the root of the backend if using relative paths
script_path = os.path.join(os.getcwd(), SCRIPTS_DIR, "get_scenarios.sh")
if not os.path.exists(script_path):
# Fallback for docker absolute path
script_path = f"/app/scripts/get_scenarios.sh"
result = subprocess.run(
[script_path, branch_name],
capture_output=True,
text=True,
check=True
)
# Expecting JSON output from script
return json.loads(result.stdout)
except subprocess.CalledProcessError as e:
print(f"Error getting scenarios: {e.stderr}")
return []
except Exception as e:
print(f"Error: {e}")
return []
async def run_job_task(job_id: int):
db = database.SessionLocal()
try:
job = crud.get_job(db, job_id)
if not job:
return
crud.update_job_status(db, job_id, "running")
await manager.broadcast(json.dumps({"type": "job_update", "job_id": job_id, "status": "running"}))
# Run the script
# run_tests.sh <branch> <scenarios_json> <env> <mode> <job_id>
scenarios_str = json.dumps(job.scenarios)
script_path = os.path.join(os.getcwd(), SCRIPTS_DIR, "run_tests.sh")
if not os.path.exists(script_path):
script_path = f"/app/scripts/run_tests.sh"
process = await asyncio.create_subprocess_exec(
script_path,
job.branch_name,
scenarios_str,
job.environment,
job.test_mode,
str(job_id),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
# Assume script generates index.html at /results/{job_id}/index.html
# We need to map where the script writes.
# For now, let's assume the script handles the file writing to a shared volume.
# In Docker, we'll mount /app/results
result_path = f"/results/{job_id}/index.html"
crud.update_job_status(db, job_id, "passed", result_path=result_path, duration="1m 30s") # Mock duration
await manager.broadcast(json.dumps({"type": "job_update", "job_id": job_id, "status": "passed"}))
else:
print(f"Script failed: {stderr.decode()}")
crud.update_job_status(db, job_id, "failed")
await manager.broadcast(json.dumps({"type": "job_update", "job_id": job_id, "status": "failed"}))
except Exception as e:
print(f"Job failed: {e}")
crud.update_job_status(db, job_id, "failed")
await manager.broadcast(json.dumps({"type": "job_update", "job_id": job_id, "status": "failed"}))
finally:
db.close()
async def cleanup_old_results():
while True:
try:
print("Running cleanup...")
results_dir = "/results"
if os.path.exists(results_dir):
now = time.time()
for job_id in os.listdir(results_dir):
job_path = os.path.join(results_dir, job_id)
if os.path.isdir(job_path):
mtime = os.path.getmtime(job_path)
if now - mtime > 7 * 86400: # 7 days
print(f"Deleting old result: {job_path}")
shutil.rmtree(job_path)
except Exception as e:
print(f"Cleanup error: {e}")
await asyncio.sleep(86400) # 24 hours

View File

@@ -0,0 +1,9 @@
fastapi
uvicorn
sqlalchemy
psycopg2-binary
pydantic
python-jose[cryptography]
passlib[bcrypt]
python-multipart
requests

View File

@@ -0,0 +1,8 @@
#!/bin/bash
# Mock script to return scenarios
# Usage: ./get_scenarios.sh <branch_name>
BRANCH=$1
# Mock output
echo '["scenario_login", "scenario_payment", "scenario_profile", "scenario_logout"]'

View File

@@ -0,0 +1,56 @@
#!/bin/bash
# Mock script to run tests
# Usage: ./run_tests.sh <branch> <scenarios_json> <env> <mode> <job_id>
BRANCH=$1
SCENARIOS=$2
ENV=$3
MODE=$4
JOB_ID=$5
echo "Starting job $JOB_ID on branch $BRANCH with env $ENV and mode $MODE"
# Simulate work
sleep 5
# Create results directory if not exists (mapped volume)
# If running locally without docker mapping, this might fail if /results doesn't exist.
# We should use a relative path for safety if not in docker, but the requirement says Docker.
# We'll assume /results is mounted.
RESULTS_DIR="/results/$JOB_ID"
# Fallback for local testing if /results is not writable
if [ ! -d "/results" ]; then
RESULTS_DIR="./results/$JOB_ID"
fi
mkdir -p $RESULTS_DIR
# Generate HTML report
cat <<EOF > $RESULTS_DIR/index.html
<!DOCTYPE html>
<html>
<head>
<title>Test Results - Job $JOB_ID</title>
<style>body { font-family: sans-serif; padding: 20px; } .pass { color: green; } .fail { color: red; }</style>
</head>
<body>
<h1>Test Results for Job $JOB_ID</h1>
<p>Branch: $BRANCH</p>
<p>Environment: $ENV</p>
<p>Mode: $MODE</p>
<hr>
<h2>Scenarios</h2>
<ul>
<li class="pass">scenario_login: PASSED</li>
<li class="pass">scenario_payment: PASSED</li>
<li class="pass">scenario_profile: PASSED</li>
<li class="pass">scenario_logout: PASSED</li>
</ul>
<p><strong>Overall Status: PASSED</strong></p>
</body>
</html>
EOF
echo "Job $JOB_ID completed."
exit 0

View File

@@ -0,0 +1,63 @@
version: '3.8'
services:
db:
image: postgres:13
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=testarena
networks:
- app-network
restart: always
backend:
build: ./backend
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
volumes:
- ./backend:/app
- ./results:/results
environment:
- DATABASE_URL=postgresql://user:password@db/testarena
- SCRIPTS_DIR=/app/scripts
depends_on:
- db
networks:
- app-network
restart: always
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
networks:
- app-network
restart: always
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./results:/srv/results
- caddy_data:/data
- caddy_config:/config
depends_on:
- backend
- frontend
networks:
- app-network
restart: always
networks:
app-network:
driver: bridge
volumes:
postgres_data:
caddy_data:
caddy_config:

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,6 @@
:80 {
root * /srv
encode gzip
file_server
try_files {path} /index.html
}

View File

@@ -0,0 +1,10 @@
FROM node:18-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM caddy:2-alpine
COPY --from=build /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"clsx": "^2.1.1",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,35 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import NewJob from './pages/NewJob';
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuth();
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
};
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
} />
<Route path="/new-job" element={
<PrivateRoute>
<NewJob />
</PrivateRoute>
} />
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
</Router>
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,17 @@
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: API_URL,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,59 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
interface User {
username: string;
role: string;
}
interface AuthContextType {
user: User | null;
login: (token: string, user: User) => void;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
setIsAuthenticated(true);
setUser(JSON.parse(storedUser));
}
setLoading(false);
}, []);
const login = (token: string, userData: User) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
setIsAuthenticated(true);
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null);
setIsAuthenticated(false);
};
if (loading) return <div className="flex items-center justify-center h-screen">Loading...</div>;
return (
<AuthContext.Provider value={{ user, login, logout, isAuthenticated }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within an AuthProvider');
return context;
};

View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-slate-900;
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,193 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import { useNavigate } from 'react-router-dom';
import { CheckCircle, XCircle, Clock, AlertCircle, Plus, LogOut } from 'lucide-react';
import clsx from 'clsx';
interface Job {
id: number;
branch_name: string;
status: string;
duration: string;
result_path: string;
created_at: string;
scenarios: string[];
environment: string;
test_mode: string;
}
const Dashboard: React.FC = () => {
const { logout } = useAuth();
const [jobs, setJobs] = useState<Job[]>([]);
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const navigate = useNavigate();
useEffect(() => {
fetchJobs();
const wsUrl = import.meta.env.VITE_API_URL
? import.meta.env.VITE_API_URL.replace('http', 'ws') + '/ws/jobs'
: 'ws://localhost:8000/ws/jobs';
const socket = new WebSocket(wsUrl);
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'job_update') {
updateJobStatus(data.job_id, data.status);
}
};
return () => socket.close();
}, []);
const fetchJobs = async () => {
try {
const response = await api.get('/jobs');
setJobs(response.data);
} catch (error) {
console.error("Failed to fetch jobs", error);
}
};
const updateJobStatus = (jobId: number, status: string) => {
setJobs(prev => prev.map(job =>
job.id === jobId ? { ...job, status } : job
));
if (selectedJob?.id === jobId) {
setSelectedJob(prev => prev ? { ...prev, status } : null);
}
};
const handleAbort = async (jobId: number) => {
try {
await api.post(`/jobs/${jobId}/abort`);
fetchJobs(); // Refresh to ensure sync
} catch (error) {
console.error("Failed to abort job", error);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'passed': return <CheckCircle className="text-green-500" />;
case 'failed': return <XCircle className="text-red-500" />;
case 'running': return <Clock className="text-orange-500 animate-pulse" />;
case 'aborted': return <AlertCircle className="text-red-900" />;
default: return <Clock className="text-gray-400" />;
}
};
return (
<div className="flex h-screen bg-gray-100 overflow-hidden">
{/* Sidebar */}
<div className="w-1/3 bg-white border-r border-gray-200 flex flex-col">
<div className="p-4 border-b border-gray-200 flex justify-between items-center bg-primary text-white">
<h1 className="font-bold text-lg">TestArena</h1>
<div className="flex gap-2">
<button onClick={() => navigate('/new-job')} className="p-1 hover:bg-secondary rounded" title="New Job">
<Plus size={20} />
</button>
<button onClick={logout} className="p-1 hover:bg-secondary rounded" title="Logout">
<LogOut size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{jobs.map(job => (
<div
key={job.id}
onClick={() => setSelectedJob(job)}
className={clsx(
"p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 flex items-center justify-between",
selectedJob?.id === job.id && "bg-blue-50 border-l-4 border-l-accent"
)}
>
<div>
<div className="font-medium">#{job.id} {job.branch_name}</div>
<div className="text-xs text-gray-500">{new Date(job.created_at).toLocaleString()}</div>
</div>
<div>{getStatusIcon(job.status)}</div>
</div>
))}
</div>
</div>
{/* Main Content */}
<div className="flex-1 p-8 overflow-y-auto">
{selectedJob ? (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold">Job #{selectedJob.id} Details</h2>
{selectedJob.status === 'running' && (
<button
onClick={() => handleAbort(selectedJob.id)}
className="bg-red-100 text-red-700 px-4 py-2 rounded hover:bg-red-200"
>
Abort Job
</button>
)}
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-gray-50 p-4 rounded">
<div className="text-sm text-gray-500">Status</div>
<div className="flex items-center gap-2 font-medium capitalize">
{getStatusIcon(selectedJob.status)}
{selectedJob.status}
</div>
</div>
<div className="bg-gray-50 p-4 rounded">
<div className="text-sm text-gray-500">Duration</div>
<div className="font-medium">{selectedJob.duration || '-'}</div>
</div>
<div className="bg-gray-50 p-4 rounded">
<div className="text-sm text-gray-500">Branch</div>
<div className="font-medium">{selectedJob.branch_name}</div>
</div>
<div className="bg-gray-50 p-4 rounded">
<div className="text-sm text-gray-500">Environment</div>
<div className="font-medium">{selectedJob.environment}</div>
</div>
<div className="bg-gray-50 p-4 rounded">
<div className="text-sm text-gray-500">Test Mode</div>
<div className="font-medium">{selectedJob.test_mode}</div>
</div>
</div>
<div className="mb-6">
<h3 className="font-bold mb-2">Selected Scenarios</h3>
<div className="flex flex-wrap gap-2">
{selectedJob.scenarios.map(s => (
<span key={s} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm">
{s}
</span>
))}
</div>
</div>
{selectedJob.result_path && (
<div className="mt-6">
<a
href={selectedJob.result_path}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
>
View HTML Report
</a>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
Select a job to view details
</div>
)}
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import { Lock, User } from 'lucide-react';
const Login: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await api.post('/auth/login', { username, password });
const { access_token } = response.data;
const role = username === 'admin' ? 'admin' : 'user';
login(access_token, { username, role });
navigate('/dashboard');
} catch (err) {
setError('Invalid credentials');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-96">
<h2 className="text-2xl font-bold mb-6 text-center text-primary">ASF TestArena</h2>
{error && <div className="bg-red-100 text-red-700 p-2 rounded mb-4 text-sm">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
<div className="relative">
<User className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<input
type="text"
className="pl-10 w-full border border-gray-300 rounded-md p-2 focus:ring-accent focus:border-accent outline-none"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="relative">
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<input
type="password"
className="pl-10 w-full border border-gray-300 rounded-md p-2 focus:ring-accent focus:border-accent outline-none"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</div>
<button
type="submit"
className="w-full bg-accent text-white py-2 rounded-md hover:bg-blue-600 transition-colors"
>
Login
</button>
</form>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,168 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { ArrowLeft, ArrowRight, Play } from 'lucide-react';
const NewJob: React.FC = () => {
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [branch, setBranch] = useState('');
const [scenarios, setScenarios] = useState<string[]>([]);
const [selectedScenarios, setSelectedScenarios] = useState<string[]>([]);
const [environment, setEnvironment] = useState('Sensor hub');
const [testMode, setTestMode] = useState('devbench-simulator');
const [loading, setLoading] = useState(false);
const handleBranchSubmit = async () => {
setLoading(true);
try {
const response = await api.post('/jobs/scenarios', null, { params: { branch_name: branch } });
setScenarios(response.data);
setStep(2);
} catch (error) {
alert('Failed to get scenarios');
} finally {
setLoading(false);
}
};
const handleStartJob = async () => {
try {
await api.post('/jobs/submit', {
branch_name: branch,
scenarios: selectedScenarios,
environment,
test_mode: testMode
});
navigate('/dashboard');
} catch (error) {
alert('Failed to submit job');
}
};
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-8">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-bold">New Job Submission</h1>
<button onClick={() => navigate('/dashboard')} className="text-gray-500 hover:text-gray-700">Cancel</button>
</div>
{/* Progress Bar */}
<div className="flex mb-8">
{[1, 2, 3, 4].map(i => (
<div key={i} className={`flex-1 h-2 rounded-full mx-1 ${step >= i ? 'bg-accent' : 'bg-gray-200'}`} />
))}
</div>
{step === 1 && (
<div>
<h2 className="text-xl font-semibold mb-4">Step 1: Enter Branch Name</h2>
<input
type="text"
className="w-full border border-gray-300 rounded p-2 mb-4"
placeholder="e.g. feature/login-fix"
value={branch}
onChange={(e) => setBranch(e.target.value)}
/>
<div className="flex justify-end">
<button
onClick={handleBranchSubmit}
disabled={!branch || loading}
className="bg-primary text-white px-4 py-2 rounded flex items-center gap-2 disabled:opacity-50"
>
{loading ? 'Loading...' : 'Next'} <ArrowRight size={16} />
</button>
</div>
</div>
)}
{step === 2 && (
<div>
<h2 className="text-xl font-semibold mb-4">Step 2: Select Scenarios</h2>
<div className="mb-4 max-h-60 overflow-y-auto border border-gray-200 rounded p-2">
{scenarios.map(s => (
<label key={s} className="flex items-center p-2 hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={selectedScenarios.includes(s)}
onChange={(e) => {
if (e.target.checked) setSelectedScenarios([...selectedScenarios, s]);
else setSelectedScenarios(selectedScenarios.filter(x => x !== s));
}}
className="mr-2"
/>
{s}
</label>
))}
</div>
<div className="flex justify-between">
<button onClick={() => setStep(1)} className="text-gray-600 flex items-center gap-2"><ArrowLeft size={16} /> Back</button>
<button
onClick={() => setStep(3)}
disabled={selectedScenarios.length === 0}
className="bg-primary text-white px-4 py-2 rounded flex items-center gap-2 disabled:opacity-50"
>
Next <ArrowRight size={16} />
</button>
</div>
</div>
)}
{step === 3 && (
<div>
<h2 className="text-xl font-semibold mb-4">Step 3: Select Environment</h2>
<div className="space-y-2 mb-4">
{['Sensor hub', 'Main board'].map(opt => (
<label key={opt} className="flex items-center p-3 border rounded cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="env"
checked={environment === opt}
onChange={() => setEnvironment(opt)}
className="mr-2"
/>
{opt}
</label>
))}
</div>
<div className="flex justify-between">
<button onClick={() => setStep(2)} className="text-gray-600 flex items-center gap-2"><ArrowLeft size={16} /> Back</button>
<button onClick={() => setStep(4)} className="bg-primary text-white px-4 py-2 rounded flex items-center gap-2">
Next <ArrowRight size={16} />
</button>
</div>
</div>
)}
{step === 4 && (
<div>
<h2 className="text-xl font-semibold mb-4">Step 4: Select Test Mode</h2>
<div className="space-y-2 mb-4">
{['devbench-simulator', 'testbench-HIL'].map(opt => (
<label key={opt} className="flex items-center p-3 border rounded cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="mode"
checked={testMode === opt}
onChange={() => setTestMode(opt)}
className="mr-2"
/>
{opt}
</label>
))}
</div>
<div className="flex justify-between">
<button onClick={() => setStep(3)} className="text-gray-600 flex items-center gap-2"><ArrowLeft size={16} /> Back</button>
<button onClick={handleStartJob} className="bg-green-600 text-white px-6 py-2 rounded flex items-center gap-2 hover:bg-green-700">
<Play size={16} /> Start Job
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default NewJob;

View File

@@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: '#0f172a', // Slate 900
secondary: '#334155', // Slate 700
accent: '#3b82f6', // Blue 500
}
},
},
plugins: [],
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB