cleanup(validation): remove v1 validation routes and frontend pages
Delete deprecated code that has been fully replaced by v2: - backend: validation.py routes, validation_run_service.py - frontend: validation.js API module, validation/ pages
This commit is contained in:
@@ -1,279 +0,0 @@
|
||||
# #region ValidationRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, validation, api, task, run]
|
||||
# @BRIEF API routes for validation task management and run history.
|
||||
# @LAYER API
|
||||
# @RELATION DEPENDS_ON -> [ValidationTaskService]
|
||||
# @RELATION DEPENDS_ON -> [ValidationRunService]
|
||||
# @RELATION DEPENDS_ON -> [TaskManager]
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.database import get_db
|
||||
from ...core.logger import logger
|
||||
from ...core.scheduler import SchedulerService
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...dependencies import get_config_manager, get_current_user, get_scheduler_service, get_task_manager, has_permission
|
||||
from ...schemas.auth import User
|
||||
from ...schemas.validation import (
|
||||
TriggerRunResponse,
|
||||
ValidationRunDetailResponse,
|
||||
ValidationRunListResponse,
|
||||
ValidationTaskCreate,
|
||||
ValidationTaskListResponse,
|
||||
ValidationTaskResponse,
|
||||
ValidationTaskUpdate,
|
||||
)
|
||||
from ...services.validation_run_service import ValidationRunService
|
||||
from ...services.validation_service import ValidationTaskService
|
||||
|
||||
# #region router [C:1] [TYPE Global]
|
||||
router = APIRouter(tags=["Validation"])
|
||||
# #endregion router
|
||||
|
||||
|
||||
# #region _get_task_service [C:1] [TYPE Function]
|
||||
def _get_task_service(
|
||||
db: Session = Depends(get_db),
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ValidationTaskService:
|
||||
return ValidationTaskService(db=db, config_manager=config_manager, username=current_user.username)
|
||||
# #endregion _get_task_service
|
||||
|
||||
|
||||
# #region _get_run_service [C:1] [TYPE Function]
|
||||
def _get_run_service(
|
||||
db: Session = Depends(get_db),
|
||||
) -> ValidationRunService:
|
||||
return ValidationRunService(db=db)
|
||||
# #endregion _get_run_service
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tasks CRUD
|
||||
# ============================================================
|
||||
|
||||
|
||||
# #region list_tasks [C:4] [TYPE Function]
|
||||
# @RATIONALE Route handler delegates to service with permission check — keeps API layer thin and testable; auth gate happens before any business logic.
|
||||
# @REJECTED Business logic in route handler rejected — would duplicate validation logic and make permission checks harder to audit.
|
||||
@router.get("/tasks", response_model=ValidationTaskListResponse)
|
||||
async def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
is_active: bool | None = Query(None),
|
||||
environment_id: str | None = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.task", "VIEW")),
|
||||
service: ValidationTaskService = Depends(_get_task_service),
|
||||
):
|
||||
"""List all validation tasks with pagination and optional filters."""
|
||||
logger.reason(f"list_tasks — User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
total, items = service.list_tasks(is_active=is_active, environment_id=environment_id, page=page, page_size=page_size)
|
||||
return ValidationTaskListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
# #endregion list_tasks
|
||||
|
||||
|
||||
# #region create_task [C:4] [TYPE Function]
|
||||
# @RATIONALE Returns 201 with created resource — follows REST conventions for POST resource creation; signals to clients that a new entity was created.
|
||||
# @REJECTED Returning 200 with wrapped response rejected — non-standard for POST create; 201 Created signals the correct semantics to HTTP clients and code generators.
|
||||
@router.post("/tasks", response_model=ValidationTaskResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
payload: ValidationTaskCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.task", "CREATE")),
|
||||
service: ValidationTaskService = Depends(_get_task_service),
|
||||
scheduler: SchedulerService = Depends(get_scheduler_service),
|
||||
):
|
||||
"""Create a new validation task with provider and environment validation."""
|
||||
logger.reason(f"create_task — User: {current_user.username}, name: {payload.name}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
result = service.create_task(payload)
|
||||
scheduler.reload_validation_policy(result.id)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
|
||||
# #endregion create_task
|
||||
|
||||
|
||||
# #region get_task [C:4] [TYPE Function]
|
||||
# @RATIONALE Joins task detail with recent runs in a single endpoint — reduces frontend API calls from 2 to 1, improving page load performance.
|
||||
# @REJECTED Separate /tasks/{id}/runs endpoint for recent runs rejected — would require frontend to make 2 sequential API calls, doubling load time on the config page.
|
||||
@router.get("/tasks/{task_id}", response_model=dict)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.task", "VIEW")),
|
||||
task_service: ValidationTaskService = Depends(_get_task_service),
|
||||
run_service: ValidationRunService = Depends(_get_run_service),
|
||||
):
|
||||
"""Get a validation task with its recent runs."""
|
||||
logger.reason(f"get_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
task = task_service.get_task(task_id)
|
||||
# Find recent runs for this specific task via task_id
|
||||
recent = run_service.list_runs(
|
||||
task_id=task_id,
|
||||
dashboard_id=task.dashboard_ids[0] if task.dashboard_ids else None,
|
||||
environment_id=task.environment_id,
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
return {
|
||||
"task": task.model_dump(),
|
||||
"recent_runs": [r.model_dump() for r in recent[1]],
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
# #endregion get_task
|
||||
|
||||
|
||||
# #region update_task [C:4] [TYPE Function]
|
||||
# @RATIONALE PUT semantics for full resource update — consistent with existing API patterns; Pydantic's exclude_unset handles partial updates while keeping PUT as the endpoint verb.
|
||||
# @REJECTED PATCH semantics rejected — using PUT with exclude_unset provides the same partial-update flexibility without introducing a second HTTP method for the same resource.
|
||||
@router.put("/tasks/{task_id}", response_model=ValidationTaskResponse)
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
payload: ValidationTaskUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.task", "EDIT")),
|
||||
service: ValidationTaskService = Depends(_get_task_service),
|
||||
scheduler: SchedulerService = Depends(get_scheduler_service),
|
||||
):
|
||||
"""Update a validation task."""
|
||||
logger.reason(f"update_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
result = service.update_task(task_id, payload)
|
||||
scheduler.reload_validation_policy(result.id)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
# #endregion update_task
|
||||
|
||||
|
||||
# #region delete_task [C:4] [TYPE Function]
|
||||
# @RATIONALE DELETE returns 204 No Content per REST convention — minimizes response payload for delete operations; supports optional delete_runs query param for cascade control.
|
||||
# @REJECTED Returning deleted resource in response body rejected — unnecessary data transfer; 204 with no body is the REST standard for delete operations.
|
||||
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_task(
|
||||
task_id: str,
|
||||
delete_runs: bool = Query(False, description="Also delete associated validation runs"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.task", "DELETE")),
|
||||
service: ValidationTaskService = Depends(_get_task_service),
|
||||
scheduler: SchedulerService = Depends(get_scheduler_service),
|
||||
):
|
||||
"""Delete a validation task."""
|
||||
logger.reason(f"delete_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
service.delete_task(task_id, delete_runs=delete_runs)
|
||||
scheduler.remove_validation_job(task_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
# #endregion delete_task
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Run — trigger immediate validation
|
||||
# ============================================================
|
||||
|
||||
|
||||
# #region trigger_run [C:4] [TYPE Function]
|
||||
# @RATIONALE Returns spawned task ID so frontend can poll for completion — enables progress tracking without WebSocket. Async spawn is essential since LLM validation can take 30+ seconds.
|
||||
# @REJECTED Blocking until run completes rejected — LLM-based validation is slow (30-120s); synchronous wait would timeout HTTP requests and create terrible UX.
|
||||
@router.post("/tasks/{task_id}/run", response_model=TriggerRunResponse)
|
||||
async def trigger_run(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.task", "EXECUTE")),
|
||||
service: ValidationTaskService = Depends(_get_task_service),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
):
|
||||
"""Trigger immediate validation for a task."""
|
||||
logger.reason(f"trigger_run — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
spawned_task_id = await service.trigger_run(task_id, task_manager)
|
||||
return TriggerRunResponse(task_id=task_id, spawned_task_id=spawned_task_id, status="PENDING", message="Validation task spawned")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
|
||||
# #endregion trigger_run
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Runs — history listing and detail
|
||||
# ============================================================
|
||||
|
||||
|
||||
# #region list_runs [C:4] [TYPE Function]
|
||||
# @RATIONALE Exposes 6 query parameters for cross-task run filtering — matches the run service's filter capabilities exactly, enabling flexible history exploration.
|
||||
# @REJECTED Fixed filter set rejected — would limit the history page's debugging use cases; users need to filter by task, status, dashboard, environment, and date range.
|
||||
@router.get("/runs", response_model=ValidationRunListResponse)
|
||||
async def list_runs(
|
||||
task_id: str | None = Query(None, description="Filter by spawned task ID"),
|
||||
dashboard_id: str | None = Query(None, description="Filter by dashboard ID"),
|
||||
environment_id: str | None = Query(None, description="Filter by environment ID"),
|
||||
status: str | None = Query(None, description="Filter by status: PASS, WARN, FAIL, UNKNOWN"),
|
||||
date_from: date | None = Query(None, description="Start date (ISO format)"),
|
||||
date_to: date | None = Query(None, description="End date (ISO format)"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.run", "VIEW")),
|
||||
service: ValidationRunService = Depends(_get_run_service),
|
||||
):
|
||||
"""List validation runs with cross-task filtering and pagination."""
|
||||
logger.reason(f"list_runs — User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
total, items = service.list_runs(
|
||||
task_id=task_id, dashboard_id=dashboard_id, environment_id=environment_id,
|
||||
status=status, date_from=date_from, date_to=date_to, page=page, page_size=page_size,
|
||||
)
|
||||
return ValidationRunListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
# #endregion list_runs
|
||||
|
||||
|
||||
# #region get_run_detail [C:4] [TYPE Function]
|
||||
# @RATIONALE Returns full run detail including issues list and raw response for the report view — single endpoint serves the complete detail page without multiple fetches.
|
||||
# @REJECTED Paginated issues endpoint rejected — issues per run are typically < 20 items; pagination overhead is unnecessary for such small collections.
|
||||
@router.get("/runs/{run_id}", response_model=ValidationRunDetailResponse)
|
||||
async def get_run_detail(
|
||||
run_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.run", "VIEW")),
|
||||
service: ValidationRunService = Depends(_get_run_service),
|
||||
):
|
||||
"""Get detailed validation run info including issues and raw response."""
|
||||
logger.reason(f"get_run_detail — Run: {run_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
return service.get_run_detail(run_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
# #endregion get_run_detail
|
||||
|
||||
|
||||
# #region delete_run [C:3] [TYPE Function]
|
||||
# @RATIONALE Returns 404 when run not found — explicit error handling rather than silent success; helps frontend distinguish between successful delete and missing resource.
|
||||
# @REJECTED Silent success on missing run rejected — would mask bugs in the UI that reference already-deleted runs; explicit 404 helps debugging.
|
||||
@router.delete("/runs/{run_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_run(
|
||||
run_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.run", "DELETE")),
|
||||
service: ValidationRunService = Depends(_get_run_service),
|
||||
):
|
||||
"""Delete a validation run record."""
|
||||
logger.reason(f"delete_run — Run: {run_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
if not service.delete_run(run_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found")
|
||||
# #endregion delete_run
|
||||
|
||||
|
||||
# #endregion ValidationRoutes
|
||||
@@ -1,141 +0,0 @@
|
||||
# #region ValidationRunService [C:3] [TYPE Module] [SEMANTICS validation, run, service, sqlalchemy]
|
||||
# @BRIEF Business logic for validation run queries: list, detail, delete.
|
||||
# @LAYER Service
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.logger import logger
|
||||
from ..models.llm import ValidationPolicy, ValidationRecord
|
||||
from ..schemas.validation import ValidationRunDetailResponse, ValidationRunResponse
|
||||
|
||||
|
||||
# #region ValidationRunService [C:4] [TYPE Class]
|
||||
# @BRIEF Queries and manages validation run history records.
|
||||
# @RATIONALE Separates run history queries from task CRUD — run data has different access patterns (filter-heavy, paginated) than task configuration.
|
||||
# @REJECTED Merging run service into task service rejected — would create a large class with mixed responsibilities and different query patterns.
|
||||
class ValidationRunService:
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# #region _resolve_task_name [C:2] [TYPE Function]
|
||||
def _resolve_task_name(self, record: ValidationRecord) -> str | None:
|
||||
"""Find matching ValidationPolicy name by dashboard + environment."""
|
||||
if not record.dashboard_id or not record.environment_id:
|
||||
return None
|
||||
candidates = (
|
||||
self.db.query(ValidationPolicy)
|
||||
.filter(ValidationPolicy.environment_id == record.environment_id)
|
||||
.all()
|
||||
)
|
||||
for policy in candidates:
|
||||
dids = list(policy.dashboard_ids) if policy.dashboard_ids else []
|
||||
if record.dashboard_id in dids:
|
||||
return policy.name
|
||||
return None
|
||||
|
||||
# #endregion _resolve_task_name
|
||||
|
||||
# #region list_runs [C:3] [TYPE Function]
|
||||
# @RATIONALE Supports 6 optional filters + pagination at the query level, enabling flexible cross-task run exploration without application-level filtering.
|
||||
# @REJECTED Application-level filtering after loading all runs rejected — would be unscalable with large run histories; database-level WHERE clauses are more efficient.
|
||||
def list_runs(
|
||||
self,
|
||||
task_id: str | None = None,
|
||||
dashboard_id: str | None = None,
|
||||
environment_id: str | None = None,
|
||||
status: str | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[int, list[ValidationRunResponse]]:
|
||||
query = self.db.query(ValidationRecord)
|
||||
|
||||
if task_id:
|
||||
# Match both spawned task_id (TaskRecord) and policy_id (ValidationPolicy.id)
|
||||
# because the frontend sends the validation policy UUID, but ValidationRecord.task_id
|
||||
# stores the spawned task UUID. Fall back to policy_id for the common case.
|
||||
query = query.filter(
|
||||
(ValidationRecord.task_id == task_id) | (ValidationRecord.policy_id == task_id)
|
||||
)
|
||||
if dashboard_id:
|
||||
query = query.filter(ValidationRecord.dashboard_id == dashboard_id)
|
||||
if environment_id:
|
||||
query = query.filter(ValidationRecord.environment_id == environment_id)
|
||||
if status:
|
||||
query = query.filter(ValidationRecord.status == status.upper())
|
||||
if date_from:
|
||||
query = query.filter(ValidationRecord.timestamp >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(ValidationRecord.timestamp <= date_to)
|
||||
|
||||
total = query.count()
|
||||
records = (
|
||||
query.order_by(ValidationRecord.timestamp.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = [
|
||||
ValidationRunResponse(
|
||||
id=r.id,
|
||||
task_id=r.task_id,
|
||||
dashboard_id=r.dashboard_id,
|
||||
environment_id=r.environment_id,
|
||||
status=r.status,
|
||||
summary=r.summary or "",
|
||||
timestamp=r.timestamp,
|
||||
screenshot_path=r.screenshot_path,
|
||||
task_name=self._resolve_task_name(r),
|
||||
issues_count=len(r.issues) if r.issues else 0,
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
return total, items
|
||||
|
||||
# #endregion list_runs
|
||||
|
||||
# #region get_run_detail [C:3] [TYPE Function]
|
||||
# @RATIONALE Returns full join including issues list and raw_response for the report UI — single query avoids N+1 when displaying the run detail page.
|
||||
# @REJECTED Lazy-loading issues and raw_response separately rejected — would cause N+1 queries on the detail page; both fields are always needed for the report view.
|
||||
def get_run_detail(self, run_id: str) -> ValidationRunDetailResponse:
|
||||
record = self.db.query(ValidationRecord).filter(ValidationRecord.id == run_id).first()
|
||||
if not record:
|
||||
raise ValueError(f"Validation run '{run_id}' not found")
|
||||
|
||||
return ValidationRunDetailResponse(
|
||||
id=record.id,
|
||||
task_id=record.task_id,
|
||||
dashboard_id=record.dashboard_id,
|
||||
environment_id=record.environment_id,
|
||||
status=record.status,
|
||||
summary=record.summary or "",
|
||||
timestamp=record.timestamp,
|
||||
screenshot_path=record.screenshot_path,
|
||||
issues=list(record.issues) if record.issues else [],
|
||||
raw_response=record.raw_response,
|
||||
task_name=self._resolve_task_name(record),
|
||||
)
|
||||
|
||||
# #endregion get_run_detail
|
||||
|
||||
# #region delete_run [C:2] [TYPE Function]
|
||||
# @RATIONALE Cleans up the database record only — screenshot cleanup is caller responsibility to maintain separation of concerns between DB and filesystem.
|
||||
# @REJECTED Automatic screenshot file deletion in service layer rejected — would couple DB cleanup to filesystem operations, complicating testing and error handling.
|
||||
def delete_run(self, run_id: str) -> bool:
|
||||
record = self.db.query(ValidationRecord).filter(ValidationRecord.id == run_id).first()
|
||||
if not record:
|
||||
return False
|
||||
self.db.delete(record)
|
||||
self.db.commit()
|
||||
logger.info("[ValidationRunService] Deleted run '%s'", run_id)
|
||||
return True
|
||||
|
||||
# #endregion delete_run
|
||||
|
||||
# #endregion ValidationRunService
|
||||
# #endregion ValidationRunService
|
||||
@@ -1,102 +0,0 @@
|
||||
// #region ValidationApi [C:2] [TYPE Module] [SEMANTICS validation, api]
|
||||
// @BRIEF API client functions for the Validation section — tasks and runs CRUD.
|
||||
// @RATIONALE Centralized API module for all validation endpoints — single import point for pages, consistent error handling through shared fetchApi/postApi/deleteApi wrappers, and easy mocking in tests.
|
||||
// @REJECTED Inline fetch calls in each page rejected — would duplicate base URL construction and auth header logic; centralized module enables consistent error handling and simplifies test mocking.
|
||||
// @RELATION CALLED_BY -> [ValidationTaskList]
|
||||
// @RELATION CALLED_BY -> [ValidationTaskConfig]
|
||||
// @RELATION CALLED_BY -> [ValidationHistory]
|
||||
|
||||
import { fetchApi, postApi, requestApi, deleteApi } from '$lib/api.js';
|
||||
|
||||
// #region buildParams:Function [C:1] [TYPE Function]
|
||||
// @BRIEF Build URLSearchParams from a params object, skipping null/undefined/empty values.
|
||||
/** @param {Record<string, any>} params */
|
||||
function buildParams(params) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
qs.append(key, String(value));
|
||||
}
|
||||
}
|
||||
return qs.toString();
|
||||
}
|
||||
// #endregion buildParams:Function
|
||||
|
||||
// ── Tasks ──────────────────────────────────────────────────────
|
||||
|
||||
// #region fetchTasks:Function [C:1] [TYPE Function]
|
||||
/** @param {{ is_active?: boolean, environment_id?: string, page?: number, page_size?: number }} [params] */
|
||||
export async function fetchTasks(params = {}) {
|
||||
const qs = buildParams(params);
|
||||
return fetchApi(`/validation/tasks${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
// #endregion fetchTasks:Function
|
||||
|
||||
// #region createTask:Function [C:1] [TYPE Function]
|
||||
/** @param {Record<string, any>} data */
|
||||
export async function createTask(data) {
|
||||
return postApi('/validation/tasks', data);
|
||||
}
|
||||
// #endregion createTask:Function
|
||||
|
||||
// #region fetchTask:Function [C:1] [TYPE Function]
|
||||
/** @param {string} id */
|
||||
export async function fetchTask(id) {
|
||||
return fetchApi(`/validation/tasks/${id}`);
|
||||
}
|
||||
// #endregion fetchTask:Function
|
||||
|
||||
// #region updateTask:Function [C:1] [TYPE Function]
|
||||
/** @param {string} id @param {Record<string, any>} data */
|
||||
export async function updateTask(id, data) {
|
||||
return requestApi(`/validation/tasks/${id}`, 'PUT', data);
|
||||
}
|
||||
// #endregion updateTask:Function
|
||||
|
||||
// #region deleteTask:Function [C:1] [TYPE Function]
|
||||
/** @param {string} id @param {boolean} [deleteRuns] */
|
||||
export async function deleteTask(id, deleteRuns = false) {
|
||||
const qs = deleteRuns ? '?delete_runs=true' : '';
|
||||
return deleteApi(`/validation/tasks/${id}${qs}`);
|
||||
}
|
||||
// #endregion deleteTask:Function
|
||||
|
||||
// #region triggerRun:Function [C:1] [TYPE Function]
|
||||
/** @param {string} id */
|
||||
export async function triggerRun(id) {
|
||||
return postApi(`/validation/tasks/${id}/run`, {});
|
||||
}
|
||||
// #endregion triggerRun:Function
|
||||
|
||||
// #region duplicateTask:Function [C:1] [TYPE Function]
|
||||
/** @param {string} id */
|
||||
export async function duplicateTask(id) {
|
||||
return postApi(`/validation/tasks/${id}/duplicate`, {});
|
||||
}
|
||||
// #endregion duplicateTask:Function
|
||||
|
||||
// ── Runs ────────────────────────────────────────────────────────
|
||||
|
||||
// #region fetchRuns:Function [C:1] [TYPE Function]
|
||||
/** @param {{ task_id?: string, dashboard_id?: string, environment_id?: string, status?: string, date_from?: string, date_to?: string, page?: number, page_size?: number }} [params] */
|
||||
export async function fetchRuns(params = {}) {
|
||||
const qs = buildParams(params);
|
||||
return fetchApi(`/validation/runs${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
// #endregion fetchRuns:Function
|
||||
|
||||
// #region fetchRunDetail:Function [C:1] [TYPE Function]
|
||||
/** @param {string} id */
|
||||
export async function fetchRunDetail(id) {
|
||||
return fetchApi(`/validation/runs/${id}`);
|
||||
}
|
||||
// #endregion fetchRunDetail:Function
|
||||
|
||||
// #region deleteRun:Function [C:1] [TYPE Function]
|
||||
/** @param {string} id */
|
||||
export async function deleteRun(id) {
|
||||
return deleteApi(`/validation/runs/${id}`);
|
||||
}
|
||||
// #endregion deleteRun:Function
|
||||
|
||||
// #endregion ValidationApi
|
||||
@@ -1,399 +0,0 @@
|
||||
<!-- #region ValidationTaskList [C:3] [TYPE Page] [SEMANTICS sveltekit, validation, task, list] -->
|
||||
<!-- @BRIEF Validation task list page with status filter, task cards, and create/duplicate/delete actions. -->
|
||||
<!-- @RATIONALE Card layout chosen over table because tasks have rich metadata (schedule, provider, last run status, environment) that does not fit into table columns without excessive horizontal scrolling. -->
|
||||
<!-- @REJECTED Table view for task list rejected — dashboard_id + environment_id + provider_id + schedule + last_run_status creates too many columns for a table; cards show all metadata vertically. -->
|
||||
<!-- @UX_STATE Loading -> 3 skeleton cards with animate-pulse -->
|
||||
<!-- @UX_STATE Error -> Alert with error message + Retry button -->
|
||||
<!-- @UX_STATE Empty -> Centered icon + descriptive message + Create Task button -->
|
||||
<!-- @UX_STATE FilteredEmpty -> "No tasks match filters" message + Clear button -->
|
||||
<!-- @UX_STATE Populated -> Task cards grid -->
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n';
|
||||
import { addToast } from '$lib/toasts.js';
|
||||
import { fetchTasks, deleteTask, duplicateTask, triggerRun } from '$lib/api/validation.js';
|
||||
|
||||
let isMounted = $state(true);
|
||||
|
||||
onDestroy(() => {
|
||||
isMounted = false;
|
||||
});
|
||||
|
||||
/** @type {'idle'|'loading'|'empty'|'populated'|'error'|'filtered_empty'} */
|
||||
let uxState = $state('idle');
|
||||
let tasks = $state([]);
|
||||
let total = $state(0);
|
||||
let error = $state(null);
|
||||
let isLoading = $state(true);
|
||||
let showDeleteConfirm = $state(null);
|
||||
let deleteWithRuns = $state(false);
|
||||
let statusFilter = $state('');
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(20);
|
||||
let runningTasks = $state({});
|
||||
|
||||
onMount(() => {
|
||||
loadTasks();
|
||||
});
|
||||
|
||||
async function loadTasks() {
|
||||
isLoading = true;
|
||||
uxState = 'loading';
|
||||
error = null;
|
||||
try {
|
||||
const result = await fetchTasks({
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
is_active: statusFilter === 'active' ? true : statusFilter === 'inactive' ? false : undefined,
|
||||
});
|
||||
if (!isMounted) return;
|
||||
const items = Array.isArray(result) ? result : (result?.results || result?.items || []);
|
||||
tasks = items;
|
||||
total = result?.total || result?.count || items.length;
|
||||
uxState = items.length === 0 ? (statusFilter ? 'filtered_empty' : 'empty') : 'populated';
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
error = err?.message || $t.validation?.load_failed || 'Failed to load tasks.';
|
||||
uxState = 'error';
|
||||
} finally {
|
||||
if (isMounted) isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} status */
|
||||
function filterByStatus(status) {
|
||||
statusFilter = status;
|
||||
currentPage = 1;
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
async function handleNavToConfig(taskId) {
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(`/validation/${taskId}`);
|
||||
}
|
||||
|
||||
async function handleDuplicate(taskId) {
|
||||
try {
|
||||
await duplicateTask(taskId);
|
||||
if (!isMounted) return;
|
||||
addToast($t.validation?.task_duplicated || 'Task duplicated.', 'success');
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
addToast(err?.message || 'Failed to duplicate.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(taskId) {
|
||||
try {
|
||||
await deleteTask(taskId, deleteWithRuns);
|
||||
if (!isMounted) return;
|
||||
addToast($t.validation?.delete_success || 'Deleted successfully.', 'success');
|
||||
showDeleteConfirm = null;
|
||||
deleteWithRuns = false;
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
addToast(err?.message || 'Failed to delete.', 'error');
|
||||
showDeleteConfirm = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunNow(taskId) {
|
||||
runningTasks = { ...runningTasks, [taskId]: true };
|
||||
try {
|
||||
await triggerRun(taskId);
|
||||
if (!isMounted) return;
|
||||
addToast($t.validation?.run_triggered || 'Validation run triggered.', 'success');
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
addToast(err?.message || 'Failed to trigger run.', 'error');
|
||||
} finally {
|
||||
if (isMounted) runningTasks = { ...runningTasks, [taskId]: false };
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status) {
|
||||
const map = {
|
||||
PASS: 'bg-green-100 text-green-700',
|
||||
WARN: 'bg-yellow-100 text-yellow-700',
|
||||
FAIL: 'bg-red-100 text-red-700',
|
||||
UNKNOWN: 'bg-gray-100 text-gray-500',
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
INACTIVE: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
return map[status] || 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
|
||||
function getRunStatusBadgeClass(status) {
|
||||
const map = {
|
||||
PASS: 'bg-green-100 text-green-700',
|
||||
WARN: 'bg-yellow-100 text-yellow-700',
|
||||
FAIL: 'bg-red-100 text-red-700',
|
||||
UNKNOWN: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
return map[status] || 'bg-gray-100 text-gray-500';
|
||||
}
|
||||
|
||||
let activeCount = $derived(tasks.filter(t => t.is_active !== false).length);
|
||||
let inactiveCount = $derived(tasks.filter(t => t.is_active === false).length);
|
||||
|
||||
let statusPills = $derived([
|
||||
{ label: 'All', value: '', count: tasks.length },
|
||||
{ label: $t.validation?.active || 'Active', value: 'active', count: activeCount },
|
||||
{ label: $t.validation?.inactive || 'Inactive', value: 'inactive', count: inactiveCount },
|
||||
]);
|
||||
|
||||
async function handleNavToNew() {
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto('/validation/new');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{$t.validation?.tasks_title || 'Validation Tasks'}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{$t.validation?.last_run_status || 'Monitor and validate dashboard quality'}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleNavToNew}
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{$t.validation?.new_task || 'New Task'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Pills -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{#each statusPills as pill (pill.value)}
|
||||
<button
|
||||
onclick={() => filterByStatus(pill.value)}
|
||||
class="px-3 py-1.5 text-sm rounded-full border transition-colors
|
||||
{statusFilter === pill.value
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
|
||||
>
|
||||
{pill.label}
|
||||
<span class="ml-1 text-xs opacity-70">({pill.count})</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if isLoading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4 animate-pulse">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-5 w-40 bg-gray-200 rounded"></div>
|
||||
<div class="h-5 w-16 bg-gray-200 rounded-full"></div>
|
||||
</div>
|
||||
<div class="h-3 w-64 bg-gray-100 rounded"></div>
|
||||
<div class="flex gap-4">
|
||||
<div class="h-3 w-24 bg-gray-100 rounded"></div>
|
||||
<div class="h-3 w-20 bg-gray-100 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="h-8 w-8 bg-gray-100 rounded"></div>
|
||||
<div class="h-8 w-8 bg-gray-100 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
{:else if uxState === 'error'}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<p class="text-red-700 mb-3">{error}</p>
|
||||
<button
|
||||
onclick={loadTasks}
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
{$t.validation?.retry || 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
{:else if uxState === 'empty'}
|
||||
<div class="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-500 mb-4">{$t.validation?.no_tasks || 'No validation tasks yet. Create your first task to start monitoring dashboards.'}</p>
|
||||
<button
|
||||
onclick={handleNavToNew}
|
||||
class="inline-flex px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{$t.validation?.create_task_cta || 'Create Task'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filtered Empty State -->
|
||||
{:else if uxState === 'filtered_empty'}
|
||||
<div class="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-500 mb-2">{$t.validation?.no_tasks_filtered || 'No tasks match your filters.'}</p>
|
||||
<button
|
||||
onclick={() => filterByStatus('')}
|
||||
class="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
{$t.validation?.clear_filters || 'Clear filters'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Populated State -->
|
||||
{:else if uxState === 'populated'}
|
||||
<div class="space-y-3">
|
||||
{#each tasks as task (task.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={() => handleNavToConfig(task.id)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleNavToConfig(task.id); }}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-md hover:border-gray-300 transition-all cursor-pointer"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 truncate">{task.name}</h3>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getStatusBadgeClass(task.is_active !== false ? 'ACTIVE' : 'INACTIVE')}">
|
||||
{task.is_active !== false ? ($t.validation?.active || 'Active') : ($t.validation?.inactive || 'Inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500">
|
||||
{#if task.dashboard_id}
|
||||
<span class="truncate max-w-[200px]" title={String(task.dashboard_id)}>
|
||||
{$t.validation?.dashboard || 'Dashboard'}: {task.dashboard_id?.substring(0, 16)}...
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.environment_id}
|
||||
<span>{$t.validation?.environment || 'Env'}: {task.environment_id?.substring(0, 12)}...</span>
|
||||
{/if}
|
||||
{#if task.last_run_status}
|
||||
<span class="flex items-center gap-1">
|
||||
{$t.validation?.last_run || 'Last run'}:
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {getRunStatusBadgeClass(task.last_run_status)}">
|
||||
{task.last_run_status}
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.last_run_at}
|
||||
<span class="text-xs text-gray-400">{new Date(task.last_run_at).toLocaleString()}</span>
|
||||
{/if}
|
||||
{#if task.provider_id}
|
||||
<span class="text-xs text-gray-400">{$t.validation?.provider || 'LLM'}: {task.provider_id?.substring(0, 12)}...</span>
|
||||
{/if}
|
||||
{#if task.schedule_days?.length}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">
|
||||
{$t.validation?.scheduled || 'Scheduled'}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">
|
||||
{$t.validation?.manual_only || 'Manual only'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="flex items-center gap-2 ml-4" onclick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onclick={() => handleRunNow(task.id)}
|
||||
disabled={runningTasks[task.id]}
|
||||
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors disabled:opacity-50"
|
||||
title={$t.validation?.run_now || 'Run Now'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDuplicate(task.id)}
|
||||
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
title={$t.validation?.duplicate_task || 'Duplicate'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if showDeleteConfirm === task.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDelete(task.id)}
|
||||
class="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
{$t.validation?.confirm_delete || 'Delete'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { showDeleteConfirm = null; deleteWithRuns = false; }}
|
||||
class="px-2 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
||||
>
|
||||
{$t.validation?.cancel_delete || 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { showDeleteConfirm = task.id; deleteWithRuns = false; }}
|
||||
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title={$t.common?.delete || 'Delete'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showDeleteConfirm === task.id}
|
||||
<div class="mt-2 pt-2 border-t border-gray-100">
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={deleteWithRuns} class="rounded border-gray-300" />
|
||||
{$t.validation?.delete_task_with_runs || 'Also delete all run history'}
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if total > pageSize}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-sm text-gray-500">
|
||||
{($t.validation?.showing || 'Showing {count} of {total}')
|
||||
.replace('{count}', String(tasks.length))
|
||||
.replace('{total}', String(total))}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => { currentPage--; loadTasks(); }}
|
||||
disabled={currentPage <= 1}
|
||||
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
{$t.validation?.previous || 'Previous'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { currentPage++; loadTasks(); }}
|
||||
disabled={currentPage * pageSize >= total}
|
||||
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
{$t.validation?.next || 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion ValidationTaskList -->
|
||||
@@ -1,731 +0,0 @@
|
||||
<!-- #region ValidationTaskConfig [C:4] [TYPE Page] [SEMANTICS sveltekit, validation, task, config, form] -->
|
||||
<!-- @BRIEF Validation task configuration page with Config and History tabs. Handles new and edit modes. -->
|
||||
<!-- @RATIONALE 2-tab design separates task configuration from run history — users can edit settings (name, dashboard, provider, schedule) without scrolling past potentially long run history data. -->
|
||||
<!-- @REJECTED Single scrollable page with config above history rejected — task config form is long (Basic Info + Dashboard & Environment + LLM Provider + Schedule + Active toggle); history would be pushed far below the fold. -->
|
||||
<!-- @UX_STATE Loading -> Full-page skeleton with form-like layout -->
|
||||
<!-- @UX_STATE Error -> Alert with error message + Retry/Go Back buttons -->
|
||||
<!-- @UX_STATE NotFound -> "Task not found" state with Go Back button -->
|
||||
<!-- @UX_STATE Configured -> Form with Save/Cancel/Run Now buttons -->
|
||||
<!-- @UX_FEEDBACK Toast notifications on save/run/delete actions -->
|
||||
<!-- @UX_RECOVERY Retry button on load errors, Go Back on not found -->
|
||||
<!-- @UX_REACTIVITY Props -> $props(), URL params -> $page.params, LocalState -> $state(...) -->
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { t } from '$lib/i18n';
|
||||
import { addToast } from '$lib/toasts.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import {
|
||||
fetchTask, createTask, updateTask, triggerRun,
|
||||
fetchRuns
|
||||
} from '$lib/api/validation.js';
|
||||
import SearchableMultiSelect from '$lib/components/ui/SearchableMultiSelect.svelte';
|
||||
|
||||
let isMounted = $state(true);
|
||||
|
||||
onDestroy(() => {
|
||||
isMounted = false;
|
||||
});
|
||||
|
||||
/** @type {'idle'|'loading'|'configured'|'saving'|'error'|'not_found'} */
|
||||
let uxState = $state('idle');
|
||||
let isNewTask = $derived(page.params.id === 'new');
|
||||
let taskId = $derived(page.params.id);
|
||||
let task = $state(null);
|
||||
let error = $state(null);
|
||||
|
||||
// Form state
|
||||
let name = $state('');
|
||||
let dashboardIds = $state([]);
|
||||
let environmentId = $state('');
|
||||
let providerId = $state('');
|
||||
let isActive = $state(true);
|
||||
let scheduleDays = $state([]);
|
||||
let scheduleStartTime = $state('');
|
||||
let scheduleEndTime = $state('');
|
||||
let showSchedule = $state(false);
|
||||
|
||||
// Reference data
|
||||
let dashboards = $state([]);
|
||||
let environments = $state([]);
|
||||
let llmProviders = $state([]);
|
||||
let appTimezone = $state('Europe/Moscow');
|
||||
let dashboardsLoading = $state(false);
|
||||
|
||||
// Run history
|
||||
let runs = $state([]);
|
||||
let runsTotal = $state(0);
|
||||
let runsPage = $state(1);
|
||||
let runsPageSize = $state(10);
|
||||
let runsLoading = $state(false);
|
||||
|
||||
// Tab
|
||||
/** @type {'config'|'history'} */
|
||||
let activeTab = $state('config');
|
||||
|
||||
// Save state
|
||||
let isSaving = $state(false);
|
||||
let isRunning = $state(false);
|
||||
let validationErrors = $state({});
|
||||
|
||||
// Schedule day labels
|
||||
const DAY_LABELS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
const DAY_KEYS = ['days_mon', 'days_tue', 'days_wed', 'days_thu', 'days_fri', 'days_sat', 'days_sun'];
|
||||
|
||||
let tabs = $derived([
|
||||
{ id: 'config', label: $t.validation?.config_tab || 'Configuration' },
|
||||
{ id: 'history', label: $t.validation?.history_tab || 'Run History' },
|
||||
]);
|
||||
|
||||
let pageTitle = $derived(
|
||||
isNewTask
|
||||
? ($t.validation?.new_task || 'New Validation Task')
|
||||
: (task?.name || name || ($t.validation?.edit_task || 'Edit Validation Task'))
|
||||
);
|
||||
|
||||
// ── Provider multimodal filter ──
|
||||
function isProviderMultimodalCheck(p) {
|
||||
return !!(p.is_multimodal || p.supports_images || p.multimodal);
|
||||
}
|
||||
let multimodalProviders = $derived(
|
||||
llmProviders.filter(isProviderMultimodalCheck)
|
||||
);
|
||||
let selectedProvider = $derived(
|
||||
multimodalProviders.find(p => String(p.id) === String(providerId))
|
||||
);
|
||||
let isProviderMultimodal = $derived(
|
||||
selectedProvider ? isProviderMultimodalCheck(selectedProvider) : false
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
await loadReferenceData();
|
||||
if (!isNewTask) {
|
||||
await loadTask();
|
||||
} else {
|
||||
uxState = 'configured';
|
||||
}
|
||||
});
|
||||
|
||||
async function loadReferenceData() {
|
||||
try {
|
||||
const envs = await api.getEnvironments?.() || [];
|
||||
environments = Array.isArray(envs) ? envs : [];
|
||||
// Pre-fill with active environment for new tasks
|
||||
if (!environmentId) {
|
||||
const { get } = await import('svelte/store');
|
||||
const envCtx = await import('$lib/stores/environmentContext.js');
|
||||
const state = get(envCtx.environmentContextStore);
|
||||
if (state.selectedEnvId && environments.some((e) => e.id === state.selectedEnvId)) {
|
||||
environmentId = state.selectedEnvId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
environments = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const consolidated = await api.getConsolidatedSettings?.() || {};
|
||||
appTimezone = consolidated.app_timezone || 'Europe/Moscow';
|
||||
llmProviders = consolidated.llm_providers || [];
|
||||
if (llmProviders.length === 0 && api.getLlmStatus) {
|
||||
const llmStatus = await api.getLlmStatus().catch(() => ({}));
|
||||
if (llmStatus?.providers) {
|
||||
llmProviders = llmStatus.providers;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
llmProviders = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function searchDashboards(search = '') {
|
||||
dashboardsLoading = true;
|
||||
try {
|
||||
const result = await api.getDashboards?.(environmentId || '', { search, page_size: 50 }) || {};
|
||||
dashboards = result?.dashboards || [];
|
||||
} catch {
|
||||
dashboards = [];
|
||||
} finally {
|
||||
dashboardsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTask() {
|
||||
uxState = 'loading';
|
||||
try {
|
||||
const response = await fetchTask(taskId);
|
||||
if (!isMounted) return;
|
||||
if (!response || !response.task) {
|
||||
uxState = 'not_found';
|
||||
return;
|
||||
}
|
||||
|
||||
task = response.task;
|
||||
name = task.name || '';
|
||||
dashboardIds = task.dashboard_ids || [];
|
||||
environmentId = task.environment_id || '';
|
||||
providerId = task.provider_id || '';
|
||||
isActive = task.is_active !== false;
|
||||
scheduleDays = task.schedule_days || [];
|
||||
scheduleStartTime = task.window_start || task.schedule_start_time || '';
|
||||
scheduleEndTime = task.window_end || task.schedule_end_time || '';
|
||||
showSchedule = scheduleDays.length > 0 || !!scheduleStartTime || !!scheduleEndTime;
|
||||
|
||||
if (environmentId) {
|
||||
await searchDashboards('');
|
||||
}
|
||||
|
||||
if (!isMounted) return;
|
||||
uxState = 'configured';
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
if (err?.status === 404) {
|
||||
uxState = 'not_found';
|
||||
} else {
|
||||
error = err?.message || $t.validation?.load_task_failed || 'Failed to load task details.';
|
||||
uxState = 'error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRunHistory() {
|
||||
runsLoading = true;
|
||||
try {
|
||||
const result = await fetchRuns({
|
||||
task_id: taskId,
|
||||
page: runsPage,
|
||||
page_size: runsPageSize,
|
||||
});
|
||||
if (!isMounted) return;
|
||||
const items = Array.isArray(result) ? result : (result?.results || result?.items || []);
|
||||
runs = items;
|
||||
runsTotal = result?.total || result?.count || items.length;
|
||||
} catch {
|
||||
if (!isMounted) return;
|
||||
runs = [];
|
||||
runsTotal = 0;
|
||||
} finally {
|
||||
if (isMounted) runsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const errors = {};
|
||||
if (!name.trim()) errors.name = $t.validation?.form_validation_required || 'This field is required';
|
||||
if (dashboardIds.length === 0) errors.dashboard_id = $t.validation?.form_validation_required || 'This field is required';
|
||||
if (!environmentId) errors.environment_id = $t.validation?.form_validation_required || 'This field is required';
|
||||
if (!providerId) errors.provider_id = $t.validation?.form_validation_required || 'This field is required';
|
||||
validationErrors = errors;
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
name: name.trim(),
|
||||
dashboard_ids: dashboardIds,
|
||||
environment_id: environmentId || null,
|
||||
provider_id: providerId || null,
|
||||
is_active: isActive,
|
||||
schedule_days: scheduleDays.length > 0 ? scheduleDays : null,
|
||||
window_start: scheduleStartTime || null,
|
||||
window_end: scheduleEndTime || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!validate()) return;
|
||||
isSaving = true;
|
||||
const payload = buildPayload();
|
||||
try {
|
||||
if (isNewTask) {
|
||||
const created = await createTask(payload);
|
||||
if (!isMounted) return;
|
||||
addToast($t.validation?.save_success || 'Task saved successfully.', 'success');
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(`/validation/${created.id}`);
|
||||
} else {
|
||||
await updateTask(taskId, payload);
|
||||
if (!isMounted) return;
|
||||
addToast($t.validation?.save_success || 'Task saved successfully.', 'success');
|
||||
uxState = 'configured';
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
addToast(err?.message || 'Failed to save.', 'error');
|
||||
} finally {
|
||||
if (isMounted) isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunNow() {
|
||||
isRunning = true;
|
||||
try {
|
||||
await triggerRun(taskId);
|
||||
if (!isMounted) return;
|
||||
addToast($t.validation?.run_triggered || 'Validation run triggered.', 'success');
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
addToast(err?.message || 'Failed to trigger run.', 'error');
|
||||
} finally {
|
||||
if (isMounted) isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavToValidation() {
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto('/validation');
|
||||
}
|
||||
|
||||
async function handleNavToReport(reportTaskId) {
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(`/reports/llm/${reportTaskId}`);
|
||||
}
|
||||
|
||||
/** @param {number} dayIndex */
|
||||
function toggleDay(dayIndex) {
|
||||
if (scheduleDays.includes(dayIndex)) {
|
||||
scheduleDays = scheduleDays.filter(d => d !== dayIndex);
|
||||
} else {
|
||||
scheduleDays = [...scheduleDays, dayIndex].sort();
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} status */
|
||||
function getRunStatusClass(status) {
|
||||
const map = {
|
||||
PASS: 'bg-green-100 text-green-700',
|
||||
WARN: 'bg-yellow-100 text-yellow-700',
|
||||
FAIL: 'bg-red-100 text-red-700',
|
||||
UNKNOWN: 'bg-gray-100 text-gray-500',
|
||||
RUNNING: 'bg-blue-100 text-blue-700',
|
||||
PENDING: 'bg-yellow-100 text-yellow-700',
|
||||
};
|
||||
return map[status] || 'bg-gray-100 text-gray-500';
|
||||
}
|
||||
|
||||
function handleTabChange(tabId) {
|
||||
activeTab = tabId;
|
||||
if (tabId === 'history' && runs.length === 0 && !runsLoading) {
|
||||
loadRunHistory();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-full xl:max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">
|
||||
{isNewTask ? ($t.validation?.new_task || 'New Validation Task') : ($t.validation?.edit_task || 'Edit Validation Task')}
|
||||
</h1>
|
||||
{#if !isNewTask && task}
|
||||
<p class="text-sm text-gray-500 mt-1">{$t.validation?.created_at || 'Created'}: {task?.created_at ? new Date(task.created_at).toLocaleString() : '-'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if !isNewTask}
|
||||
<button
|
||||
onclick={handleRunNow}
|
||||
disabled={isRunning}
|
||||
class="inline-flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if isRunning}
|
||||
<svg class="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{$t.validation?.running || 'Running...'}
|
||||
{:else}
|
||||
{$t.validation?.run_now || 'Run Now'}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleNavToValidation}
|
||||
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{$t.validation?.cancel || 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSaving ? ($t.validation?.saving || 'Saving...') : ($t.validation?.save || 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if uxState === 'loading'}
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-6 animate-pulse space-y-4">
|
||||
<div class="h-5 w-32 bg-gray-200 rounded"></div>
|
||||
<div class="h-10 w-full bg-gray-100 rounded"></div>
|
||||
<div class="h-10 w-full bg-gray-100 rounded"></div>
|
||||
<div class="h-10 w-full bg-gray-100 rounded"></div>
|
||||
<div class="h-10 w-full bg-gray-100 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
{:else if uxState === 'error'}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<p class="text-red-700 mb-3">{error || $t.validation?.load_task_failed || 'Failed to load task.'}</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<button
|
||||
onclick={loadTask}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{$t.validation?.retry || 'Retry'}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleNavToValidation}
|
||||
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{$t.validation?.back || 'Back'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not Found State -->
|
||||
{:else if uxState === 'not_found'}
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-12 text-center">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-2">{$t.validation?.task_not_found || 'Task not found'}</h2>
|
||||
<p class="text-gray-500 mb-6">{$t.validation?.task_not_found_desc || 'The task you are looking for does not exist or has been deleted.'}</p>
|
||||
<button
|
||||
onclick={handleNavToValidation}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{$t.validation?.go_back || 'Go back'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Configured State -->
|
||||
{:else if uxState === 'configured' || uxState === 'saving'}
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 mb-6">
|
||||
<nav class="flex space-x-1" aria-label="Tabs">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
onclick={() => handleTabChange(tab.id)}
|
||||
class="px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors
|
||||
{activeTab === tab.id
|
||||
? 'bg-white text-blue-600 border border-b-white border-gray-200 -mb-px'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'}"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Not multimodal warning -->
|
||||
{#if !isNewTask && providerId && !isProviderMultimodal}
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6">
|
||||
<p class="text-sm text-amber-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
{$t.validation?.provider_multimodal_required || 'Dashboard validation requires a multimodal LLM provider (supports images).'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Config Tab -->
|
||||
{#if activeTab === 'config'}
|
||||
<div class="space-y-6">
|
||||
<!-- Name -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.validation?.task_name || 'Basic Info'}</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="task-name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.validation?.task_name || 'Name'} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="task-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.name ? 'border-red-300 bg-red-50' : 'border-gray-300'} focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
placeholder={$t.validation?.task_name || 'Enter task name'}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p class="text-xs text-red-600 mt-1">{validationErrors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.validation?.dashboard || 'Dashboard & Environment'}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="task-environment" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.validation?.environment || 'Environment'} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="task-environment"
|
||||
bind:value={environmentId}
|
||||
onchange={() => { dashboardIds = []; searchDashboards(''); }}
|
||||
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.environment_id ? 'border-red-300 bg-red-50' : 'border-gray-300'}"
|
||||
>
|
||||
<option value="">{$t.validation?.environment || 'Select environment...'}</option>
|
||||
{#each environments as env (env.id)}
|
||||
<option value={env.id}>{env.name || env.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if validationErrors.environment_id}
|
||||
<p class="text-xs text-red-600 mt-1">{validationErrors.environment_id}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.validation?.dashboard || 'Dashboard'} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<SearchableMultiSelect
|
||||
options={dashboards.map(d => ({ id: String(d.id), name: d.title || d.dashboard_title || d.name || String(d.id), subtitle: d.schema || '' }))}
|
||||
bind:selected={dashboardIds}
|
||||
placeholder={$t.validation?.dashboard || 'Select dashboard...'}
|
||||
selectAllLabel={$t.dashboard?.select_all || 'Select All'}
|
||||
clearLabel="Clear"
|
||||
emptyMessage="No dashboards found"
|
||||
disabled={!environmentId}
|
||||
/>
|
||||
{#if validationErrors.dashboard_id}
|
||||
<p class="text-xs text-red-600 mt-1">{validationErrors.dashboard_id}</p>
|
||||
{/if}
|
||||
{#if dashboardsLoading}
|
||||
<p class="text-xs text-gray-400 mt-1">Loading...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LLM Provider -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.validation?.llm_provider || 'LLM Provider'}</h2>
|
||||
<div>
|
||||
<label for="task-provider" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$t.validation?.llm_provider || 'LLM Provider'} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="task-provider"
|
||||
bind:value={providerId}
|
||||
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.provider_id ? 'border-red-300 bg-red-50' : 'border-gray-300'}"
|
||||
>
|
||||
<option value="">{$t.validation?.llm_provider || 'Select provider...'}</option>
|
||||
{#each multimodalProviders as p (p.id)}
|
||||
<option value={p.id}>
|
||||
{p.name || p.provider_type || p.id}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if validationErrors.provider_id}
|
||||
<p class="text-xs text-red-600 mt-1">{validationErrors.provider_id}</p>
|
||||
{/if}
|
||||
{#if providerId && !isProviderMultimodal}
|
||||
<p class="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01" />
|
||||
</svg>
|
||||
{$t.validation?.provider_multimodal_required || 'Dashboard validation requires a multimodal LLM provider.'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Schedule -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">{$t.validation?.schedule || 'Schedule'}</h2>
|
||||
<button
|
||||
onclick={() => (showSchedule = !showSchedule)}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
{showSchedule
|
||||
? ($t.validation?.schedule_collapse || 'Hide schedule')
|
||||
: ($t.validation?.schedule_expand || 'Configure schedule')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !showSchedule}
|
||||
<p class="text-sm text-gray-400">{$t.validation?.manual_only || 'Manual only — runs triggered by user.'}</p>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{$t.validation?.schedule_days || 'Days'}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each DAY_LABELS as day, idx (day)}
|
||||
<button
|
||||
onclick={() => toggleDay(idx)}
|
||||
class="px-3 py-1.5 text-sm rounded-lg border transition-colors
|
||||
{scheduleDays.includes(idx)
|
||||
? 'bg-blue-100 border-blue-300 text-blue-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
|
||||
>
|
||||
{$t.validation?.[DAY_KEYS[idx]] || day}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="schedule-start" class="block text-sm font-medium text-gray-700 mb-1">{$t.validation?.schedule_start || 'Window Start'}</label>
|
||||
<input
|
||||
id="schedule-start"
|
||||
type="time"
|
||||
bind:value={scheduleStartTime}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="schedule-end" class="block text-sm font-medium text-gray-700 mb-1">{$t.validation?.schedule_end || 'Window End'}</label>
|
||||
<input
|
||||
id="schedule-end"
|
||||
type="time"
|
||||
bind:value={scheduleEndTime}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timezone hint -->
|
||||
<p class="text-xs text-gray-400 flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{$t.validation?.schedule_timezone_hint || 'All times in'} {appTimezone}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Active toggle -->
|
||||
<section class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isActive}
|
||||
class="mt-0.5 rounded border-gray-300 text-blue-600"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700">{$t.validation?.active || 'Active'}</span>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
{isActive
|
||||
? ($t.validation?.scheduled || 'Task will run according to schedule when active.')
|
||||
: ($t.validation?.manual_only || 'Task will only run when manually triggered.')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- History Tab -->
|
||||
{:else if activeTab === 'history'}
|
||||
<div>
|
||||
<!-- Info callout: explains what history is shown -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-sm text-blue-700">
|
||||
<p class="font-medium mb-1">{$t.validation?.history_info_title || 'About run history'}</p>
|
||||
<p>{$t.validation?.history_info_desc || 'This tab shows validation runs for this specific task. Runs appear after you click "Run Now" or when the scheduled task executes. Each run captures a dashboard screenshot and sends it to the LLM provider for analysis.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if runsLoading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<div class="h-16 bg-gray-100 rounded-lg animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if runs.length === 0}
|
||||
<div class="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-12 text-center">
|
||||
<p class="text-gray-500 mb-2">{$t.validation?.run_history_empty || 'No runs for this task yet.'}</p>
|
||||
<p class="text-sm text-gray-400">{$t.validation?.run_history_empty_hint || 'Click "Run Now" above to start a validation run. The result will appear here once analysis completes.'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.validation?.status || 'Status'}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.validation?.summary || 'Summary'}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.validation?.issues || 'Issues'}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.validation?.timestamp || 'Timestamp'}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.validation?.actions || 'Actions'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each runs as run (run.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getRunStatusClass(run.status)}">
|
||||
{run.status || 'UNKNOWN'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 truncate max-w-[200px]" title={run.summary || ''}>
|
||||
{run.summary || '-'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
|
||||
{run.issues_count ?? run.issues ?? '-'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
|
||||
{run.timestamp ? new Date(run.timestamp).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right">
|
||||
<button
|
||||
onclick={() => handleNavToReport(run.task_id)}
|
||||
class="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
{$t.validation?.view_report || 'View Report'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if runsTotal > runsPageSize}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-sm text-gray-500">
|
||||
{($t.validation?.showing || 'Showing {count} of {total}')
|
||||
.replace('{count}', String(runs.length))
|
||||
.replace('{total}', String(runsTotal))}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => { runsPage--; loadRunHistory(); }}
|
||||
disabled={runsPage <= 1}
|
||||
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
{$t.validation?.previous || 'Previous'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { runsPage++; loadRunHistory(); }}
|
||||
disabled={runsPage * runsPageSize >= runsTotal}
|
||||
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
{$t.validation?.next || 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion ValidationTaskConfig -->
|
||||
@@ -1,405 +0,0 @@
|
||||
<!-- #region ValidationHistory [C:3] [TYPE Page] [SEMANTICS sveltekit, validation, history, run, filter] -->
|
||||
<!-- @BRIEF Validation run history page with filters, metrics summary, and run cards. -->
|
||||
<!-- @RATIONALE Filter-driven design with 6 filter dimensions (task, status, dashboard, environment, date from/to) allows users to narrow runs for debugging — essential when hundreds of runs accumulate over time. -->
|
||||
<!-- @REJECTED Unfiltered chronological list rejected — users need to filter by task/status/environment to find relevant runs among hundreds; without filters the page is unusable at scale. -->
|
||||
<!-- @UX_STATE Loading -> Skeleton filter bar + 3 skeleton rows -->
|
||||
<!-- @UX_STATE Error -> Alert with error + Retry button -->
|
||||
<!-- @UX_STATE Empty -> Centered icon + message + CTA -->
|
||||
<!-- @UX_STATE FilteredEmpty -> "No runs match filters" + Clear button -->
|
||||
<!-- @UX_STATE Populated -> Metrics grid + run cards + pagination -->
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { addToast } from '$lib/toasts.js';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fetchRuns, fetchTasks, deleteRun } from '$lib/api/validation.js';
|
||||
import { api } from '$lib/api.js';
|
||||
|
||||
let isMounted = $state(true);
|
||||
|
||||
onDestroy(() => {
|
||||
isMounted = false;
|
||||
});
|
||||
|
||||
/** @type {'idle'|'loading'|'empty'|'filtered_empty'|'populated'|'error'} */
|
||||
let uxState = $state('idle');
|
||||
let runs = $state([]);
|
||||
let total = $state(0);
|
||||
let isLoading = $state(false);
|
||||
let error = $state(null);
|
||||
let tasks = $state([]);
|
||||
let environments = $state([]);
|
||||
let showDeleteConfirm = $state(null);
|
||||
|
||||
// Pagination
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(20);
|
||||
|
||||
// Filters
|
||||
let filterTaskId = $state('');
|
||||
let filterStatus = $state('');
|
||||
let filterDashboard = $state('');
|
||||
let filterEnvironmentId = $state('');
|
||||
let filterDateFrom = $state('');
|
||||
let filterDateTo = $state('');
|
||||
|
||||
// Metrics
|
||||
let totalRuns = $state(0);
|
||||
let passedCount = $state(0);
|
||||
let failedCount = $state(0);
|
||||
let warnCount = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
loadRuns();
|
||||
loadTasks();
|
||||
loadEnvironments();
|
||||
});
|
||||
|
||||
async function loadRuns() {
|
||||
isLoading = true;
|
||||
uxState = 'loading';
|
||||
error = null;
|
||||
try {
|
||||
const result = await fetchRuns({
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
task_id: filterTaskId || undefined,
|
||||
status: filterStatus || undefined,
|
||||
dashboard_id: filterDashboard || undefined,
|
||||
environment_id: filterEnvironmentId || undefined,
|
||||
date_from: filterDateFrom || undefined,
|
||||
date_to: filterDateTo || undefined,
|
||||
});
|
||||
if (!isMounted) return;
|
||||
const items = Array.isArray(result) ? result : (result?.results || result?.items || []);
|
||||
runs = items;
|
||||
total = result?.total || result?.count || items.length;
|
||||
totalRuns = total;
|
||||
|
||||
passedCount = items.filter(r => r.status === 'PASS').length;
|
||||
failedCount = items.filter(r => r.status === 'FAIL').length;
|
||||
warnCount = items.filter(r => r.status === 'WARN').length;
|
||||
|
||||
const hasFilters = filterTaskId || filterStatus || filterDashboard || filterEnvironmentId || filterDateFrom || filterDateTo;
|
||||
uxState = items.length === 0 ? (hasFilters ? 'filtered_empty' : 'empty') : 'populated';
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
error = err?.message || $t.validation?.load_runs_failed || 'Failed to load run history.';
|
||||
uxState = 'error';
|
||||
} finally {
|
||||
if (isMounted) isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const result = await fetchTasks({ page_size: 100 });
|
||||
tasks = Array.isArray(result) ? result : (result?.results || result?.items || []);
|
||||
} catch {
|
||||
tasks = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEnvironments() {
|
||||
try {
|
||||
const envs = await api.getEnvironments?.() || [];
|
||||
environments = Array.isArray(envs) ? envs : [];
|
||||
} catch {
|
||||
environments = [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
currentPage = 1;
|
||||
loadRuns();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filterTaskId = '';
|
||||
filterStatus = '';
|
||||
filterDashboard = '';
|
||||
filterEnvironmentId = '';
|
||||
filterDateFrom = '';
|
||||
filterDateTo = '';
|
||||
currentPage = 1;
|
||||
loadRuns();
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
async function handleDeleteRun(id) {
|
||||
try {
|
||||
await deleteRun(id);
|
||||
if (!isMounted) return;
|
||||
addToast($t.validation?.delete_success || 'Deleted successfully.', 'success');
|
||||
showDeleteConfirm = null;
|
||||
loadRuns();
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
addToast(err?.message || 'Failed to delete.', 'error');
|
||||
showDeleteConfirm = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavToReport(reportTaskId) {
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(`/reports/llm/${reportTaskId}`);
|
||||
}
|
||||
|
||||
/** @param {string} status */
|
||||
function getStatusClass(status) {
|
||||
const map = {
|
||||
PASS: 'bg-green-100 text-green-700',
|
||||
WARN: 'bg-yellow-100 text-yellow-700',
|
||||
FAIL: 'bg-red-100 text-red-700',
|
||||
UNKNOWN: 'bg-gray-100 text-gray-500',
|
||||
RUNNING: 'bg-blue-100 text-blue-700',
|
||||
PENDING: 'bg-yellow-100 text-yellow-700',
|
||||
};
|
||||
return map[status] || 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
|
||||
/** @param {string} taskId */
|
||||
function getTaskName(taskId) {
|
||||
const task = tasks.find(t => String(t.id) === String(taskId));
|
||||
return task?.name || taskId?.substring(0, 8) + '...';
|
||||
}
|
||||
|
||||
let warnRate = $derived(totalRuns > 0 ? Math.round((warnCount / totalRuns) * 100) : 0);
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{$t.validation?.history_title || 'Validation History'}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{$t.validation?.last_run || 'Review past validation runs'}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => { currentPage = 1; applyFilters(); }}
|
||||
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{$t.common?.refresh || 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter row -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 mb-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-3">
|
||||
<select bind:value={filterTaskId} class="px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">{$t.validation?.filter_task || 'All Tasks'}</option>
|
||||
{#each tasks as tsk (tsk.id)}
|
||||
<option value={tsk.id}>{tsk.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={filterStatus} class="px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">{$t.validation?.filter_status || 'All Statuses'}</option>
|
||||
<option value="PASS">{$t.validation?.status_pass || 'Pass'}</option>
|
||||
<option value="WARN">{$t.validation?.status_warn || 'Warn'}</option>
|
||||
<option value="FAIL">{$t.validation?.status_fail || 'Fail'}</option>
|
||||
<option value="UNKNOWN">{$t.validation?.status_unknown || 'Unknown'}</option>
|
||||
<option value="RUNNING">{$t.validation?.status_running || 'Running'}</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={filterDashboard}
|
||||
placeholder={$t.validation?.filter_dashboard || 'Dashboard'}
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
<select bind:value={filterEnvironmentId} class="px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">{$t.validation?.filter_environment || 'All Environments'}</option>
|
||||
{#each environments as env (env.id)}
|
||||
<option value={env.id}>{env.name || env.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={filterDateFrom}
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
placeholder={$t.validation?.date_from || 'From'}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={filterDateTo}
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
placeholder={$t.validation?.date_to || 'To'}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={applyFilters}
|
||||
class="flex-1 px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{$t.validation?.apply || 'Apply'}
|
||||
</button>
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="px-3 py-2 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
title={$t.validation?.clear || 'Clear'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if isLoading}
|
||||
<div class="h-16 bg-gray-100 rounded-lg animate-pulse mb-4"></div>
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<div class="h-16 bg-gray-100 rounded-lg animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
{:else if uxState === 'error'}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<p class="text-red-700 mb-3">{error}</p>
|
||||
<button
|
||||
onclick={loadRuns}
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
{$t.validation?.retry || 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
{:else if uxState === 'empty'}
|
||||
<div class="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<p class="text-gray-500 mb-2">{$t.validation?.no_runs || 'No validation runs yet. Create a task and run it.'}</p>
|
||||
</div>
|
||||
|
||||
<!-- Filtered empty state -->
|
||||
{:else if uxState === 'filtered_empty'}
|
||||
<div class="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-500 mb-2">{$t.validation?.no_runs_filtered || 'No runs match your filters.'}</p>
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
{$t.validation?.clear_filters || 'Clear filters'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Populated state -->
|
||||
{:else if uxState === 'populated'}
|
||||
<!-- Metrics Summary -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-500 uppercase">{$t.validation?.total_runs || 'Total runs'}</p>
|
||||
<p class="text-xl font-bold text-gray-900">{totalRuns}</p>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-3">
|
||||
<p class="text-xs text-green-600 uppercase">{$t.validation?.passed || 'Passed'}</p>
|
||||
<p class="text-xl font-bold text-green-700">{passedCount}</p>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-3">
|
||||
<p class="text-xs text-red-600 uppercase">{$t.validation?.failed || 'Failed'}</p>
|
||||
<p class="text-xl font-bold text-red-700">{failedCount}</p>
|
||||
</div>
|
||||
<div class="bg-yellow-50 rounded-lg p-3">
|
||||
<p class="text-xs text-yellow-600 uppercase">{$t.validation?.warn_rate || 'Warn rate'}</p>
|
||||
<p class="text-xl font-bold text-yellow-700">{warnRate}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run list -->
|
||||
<div class="space-y-2">
|
||||
{#each runs as run (run.id)}
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-sm transition-shadow">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-gray-900">{getTaskName(run.task_id)}</span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getStatusClass(run.status)}">
|
||||
{run.status || 'UNKNOWN'}
|
||||
</span>
|
||||
{#if run.issues_count > 0}
|
||||
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{$t.validation?.issues || 'Issues'}: {run.issues_count}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if run.summary}
|
||||
<p class="text-sm text-gray-500 truncate max-w-lg" title={run.summary}>{run.summary}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-3 text-xs text-gray-400 mt-1">
|
||||
<span>{run.timestamp ? new Date(run.timestamp).toLocaleString() : '-'}</span>
|
||||
<span>{$t.validation?.dashboard || 'Dashboard'}: {run.dashboard_id || getTaskName(run.task_id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4" role="none" onclick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onclick={() => handleNavToReport(run.task_id)}
|
||||
class="px-3 py-1.5 text-xs text-blue-600 hover:text-blue-700 font-medium rounded-lg hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
{$t.validation?.view_report || 'View Report'}
|
||||
</button>
|
||||
{#if showDeleteConfirm === run.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDeleteRun(run.id)}
|
||||
class="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
{$t.validation?.confirm_delete || 'Delete'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = null)}
|
||||
class="px-2 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
||||
>
|
||||
{$t.validation?.cancel_delete || 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = run.id)}
|
||||
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title={$t.common?.delete || 'Delete'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-sm text-gray-500">
|
||||
{($t.validation?.showing || 'Showing {count} of {total}')
|
||||
.replace('{count}', String(runs.length))
|
||||
.replace('{total}', String(total))}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => { currentPage--; loadRuns(); }}
|
||||
disabled={currentPage <= 1}
|
||||
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
{$t.validation?.previous || 'Previous'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { currentPage++; loadRuns(); }}
|
||||
disabled={currentPage * pageSize >= total}
|
||||
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
{$t.validation?.next || 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion ValidationHistory -->
|
||||
Reference in New Issue
Block a user