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:
2026-05-31 22:32:14 +03:00
parent 5e5f958eaa
commit 431330231f
6 changed files with 0 additions and 2057 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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 -->