feat: LLM Validation section — 9 API endpoints + 3 Svelte pages + Playwright optimization

Backend:
- /api/validation/ CRUD for validation tasks (policies)
- Run history with 6 filters + pagination
- Alembic migrations (provider_id, policy_id)
- 28 pytest tests

Frontend:
- /validation — task list page with status filters + CRUD
- /validation/[id] — task config (Config + History tabs)
- /validation/history — run history with metrics
- Sidebar nav entry under Operations
- i18n en/ru

Playwright (ScreenshotService):
- Removed 25s hardcoded sleeps → conditional waits
- CDP fallback to full_page screenshot
- Total timeout enforcement (120s)
- Debug screenshots → temp dir with cleanup

QA fixes:
- Debug screenshot accumulation in production (tempdir + rmtree)
- Frontend API payload field name mismatch
- History issues field reference fix
- delete_runs scoped by policy_id

Belief protocol:
- @RATIONALE/@REJECTED on 27 C4+ contracts

Closes: VALIDATION-REWORK-2026
This commit is contained in:
2026-05-21 20:29:36 +03:00
parent 36643024cf
commit 48cc02ea0b
22 changed files with 3726 additions and 372 deletions

View File

@@ -0,0 +1,32 @@
"""Add provider_id column to validation_policies
Revision ID: a7b1c2d3e4f5
Revises: 9f8e7d6c5b4a
Create Date: 2026-05-21 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a7b1c2d3e4f5"
down_revision: Union[str, Sequence[str], None] = "9f8e7d6c5b4a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add provider_id column to validation_policies as nullable."""
op.add_column(
"validation_policies",
sa.Column("provider_id", sa.String(), nullable=True),
)
def downgrade() -> None:
"""Remove provider_id column from validation_policies."""
op.drop_column("validation_policies", "provider_id")

View File

@@ -0,0 +1,32 @@
"""Add policy_id column to llm_validation_results
Revision ID: e5f4d3c2b1a
Revises: a7b1c2d3e4f5
Create Date: 2026-05-21 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e5f4d3c2b1a"
down_revision: Union[str, Sequence[str], None] = "a7b1c2d3e4f5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add policy_id column to llm_validation_results as nullable indexed."""
op.add_column(
"llm_validation_results",
sa.Column("policy_id", sa.String(), nullable=True, index=True),
)
def downgrade() -> None:
"""Remove policy_id column from llm_validation_results."""
op.drop_column("llm_validation_results", "policy_id")

View File

@@ -37,6 +37,7 @@ __all__ = [
"storage",
"tasks",
"translate",
"validation",
]
# #endregion Route_Group_Contracts

View File

@@ -0,0 +1,270 @@
# #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.task_manager import TaskManager
from ...dependencies import get_config_manager, get_current_user, 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),
):
"""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:
return service.create_task(payload)
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)
from ...schemas.validation import ValidationRunResponse
# Find recent runs via run_service using dashboard matching
recent = run_service.list_runs(
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),
):
"""Update a validation task."""
logger.reason(f"update_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
try:
return service.update_task(task_id, payload)
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),
):
"""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)
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

@@ -0,0 +1,550 @@
# #region ValidationApiTests [C:3] [TYPE Module] [SEMANTICS validation, api, tests, pagination, crud]
# @BRIEF Unit tests for validation task CRUD and run history API endpoints.
# @LAYER: API
# @RELATION BINDS_TO -> [ValidationRoutes]
# @RELATION DEPENDS_ON -> [EXT:FastAPI:TestClient]
# @TEST_CONTRACT: [ValidationTaskCreate|Update|RunRequest] -> [ValidationTaskResponse|RunResponse|TriggerRunResponse]
# @TEST_SCENARIO: list_tasks -> 200 with paginated tasks; filter by is_active/environment_id
# @TEST_SCENARIO: create_task -> 201 with valid payload; 422 with invalid provider/environment
# @TEST_SCENARIO: get_task -> 200 with task+recent_runs; 404 for nonexistent
# @TEST_SCENARIO: update_task -> 200 with updated fields; 404 for nonexistent
# @TEST_SCENARIO: delete_task -> 204 on success; 404 for nonexistent
# @TEST_SCENARIO: trigger_run -> 200 with spawned_task_id; 422 for invalid task
# @TEST_SCENARIO: list_runs -> 200 with 6 filters + pagination
# @TEST_SCENARIO: get_run_detail -> 200 with issues/raw_response; 404 for nonexistent
# @TEST_SCENARIO: delete_run -> 204 on success; 404 for nonexistent
# @TEST_EDGE: missing_field -> 422 when required field omitted from create payload
# @TEST_EDGE: invalid_type -> 422 when provider_id is not multimodal
# @TEST_EDGE: external_fail -> 404/422 when task/run does not exist
# @TEST_EDGE: delete_task_with_runs -> 204 with delete_runs=true flag
# @INVARIANT: Every endpoint requires authentication via has_permission
# @INVARIANT: All list endpoints support pagination (page/page_size) with defaults
# @INVARIANT: provider_id must reference a multimodal LLM provider for creation
# Set required env vars before ANY app imports — crash-early guard for AuthConfig()
import os
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth")
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:test_main")
os.environ.setdefault("DEV_MODE", "true")
from datetime import datetime, timezone
import pytest
from unittest.mock import AsyncMock, MagicMock
from fastapi.testclient import TestClient
from src.app import app
from src.dependencies import (
get_current_user,
get_config_manager,
get_task_manager,
)
from src.api.routes.validation import _get_task_service, _get_run_service
from src.schemas.validation import (
ValidationTaskListResponse,
ValidationTaskResponse,
ValidationRunListResponse,
ValidationRunDetailResponse,
TriggerRunResponse,
)
from src.models.auth import User
# ── Shared mocks ───────────────────────────────────────────────────────
mock_user = MagicMock(spec=User)
mock_user.username = "testuser"
mock_user.roles = []
admin_role = MagicMock()
admin_role.name = "Admin"
admin_role.permissions = []
mock_user.roles.append(admin_role)
def _make_now():
"""Return a timezone-aware UTC datetime for Pydantic v2."""
return datetime.now(timezone.utc)
@pytest.fixture(autouse=True)
def mock_all_deps():
"""Override service-level FastAPI dependencies with MagicMock stubs.
Permission checking (has_permission) is NOT overridden — mock_user has
Admin role which passes all permission gates implicitly.
Fixture resets mock_user.roles before each test to prevent cross-test pollution.
"""
# Reset user state — Admin role passes all permission gates
mock_user.roles = [admin_role]
mock_task_service = MagicMock()
mock_run_service = MagicMock()
mock_task_manager = MagicMock()
app.dependency_overrides[_get_task_service] = lambda: mock_task_service
app.dependency_overrides[_get_run_service] = lambda: mock_run_service
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
app.dependency_overrides[get_task_manager] = lambda: mock_task_manager
app.dependency_overrides[get_current_user] = lambda: mock_user
yield {
"task_service": mock_task_service,
"run_service": mock_run_service,
"task_manager": mock_task_manager,
}
app.dependency_overrides.clear()
client = TestClient(app)
# ── Fixtures ────────────────────────────────────────────────────────────
def _make_task_response(**overrides):
"""Build a ValidationTaskResponse for mock returns."""
return ValidationTaskResponse(
id=overrides.get("id", "task-1"),
name=overrides.get("name", "Test Task"),
environment_id=overrides.get("environment_id", "env-1"),
is_active=overrides.get("is_active", True),
dashboard_ids=overrides.get("dashboard_ids", ["dash-1"]),
provider_id=overrides.get("provider_id", "provider-1"),
schedule_days=None,
window_start=None,
window_end=None,
notify_owners=True,
custom_channels=None,
alert_condition="FAIL_ONLY",
created_at=overrides.get("created_at", _make_now()),
updated_at=None,
last_run_status=None,
last_run_at=None,
)
def _make_run_response(**overrides):
"""Build a dict representation of a ValidationRunResponse."""
return {
"id": overrides.get("id", "run-1"),
"task_id": overrides.get("task_id", "task-1"),
"dashboard_id": overrides.get("dashboard_id", "dash-1"),
"environment_id": overrides.get("environment_id", "env-1"),
"status": overrides.get("status", "PASS"),
"summary": overrides.get("summary", ""),
"timestamp": overrides.get("timestamp", _make_now()),
"screenshot_path": None,
"task_name": overrides.get("task_name", "Test Task"),
}
# ── Task CRUD Tests ────────────────────────────────────────────────────
# #region test_list_tasks_success [C:2] [TYPE Function]
# @BRIEF GET /validation/tasks returns paginated task list with filters.
def test_list_tasks_success(mock_all_deps):
task = _make_task_response()
mock_all_deps["task_service"].list_tasks.return_value = (1, [task])
response = client.get("/api/validation/tasks")
assert response.status_code == 200
data = response.json()
assert data["items"][0]["name"] == "Test Task"
assert data["total"] == 1
assert data["page"] == 1
assert data["page_size"] == 20
ValidationTaskListResponse(**data)
# environment_id filter
response = client.get("/api/validation/tasks?environment_id=env-1")
assert response.status_code == 200
mock_all_deps["task_service"].list_tasks.assert_called_with(
is_active=None, environment_id="env-1", page=1, page_size=20
)
# #endregion test_list_tasks_success
# #region test_list_tasks_pagination [C:2] [TYPE Function]
# @BRIEF Pagination query params are validated: page >= 1, page_size 1-100.
def test_list_tasks_pagination(mock_all_deps):
response = client.get("/api/validation/tasks?page=0")
assert response.status_code == 422
response = client.get("/api/validation/tasks?page_size=101")
assert response.status_code == 422
response = client.get("/api/validation/tasks?page_size=0")
assert response.status_code == 422
# #endregion test_list_tasks_pagination
# #region test_list_tasks_service_error [C:2] [TYPE Function]
# @BRIEF Service ValueError becomes 400 on list_tasks.
def test_list_tasks_service_error(mock_all_deps):
mock_all_deps["task_service"].list_tasks.side_effect = ValueError("Bad filter")
response = client.get("/api/validation/tasks?environment_id=nonexistent")
assert response.status_code == 400
assert "Bad filter" in response.json()["detail"]
# #endregion test_list_tasks_service_error
# #region test_create_task_success [C:2] [TYPE Function]
# @BRIEF POST /validation/tasks with valid payload returns 201.
def test_create_task_success(mock_all_deps):
mock_all_deps["task_service"].create_task.return_value = _make_task_response()
payload = {
"name": "My Validation Task",
"environment_id": "env-1",
"dashboard_ids": ["dash-1"],
"provider_id": "provider-1",
"notify_owners": True,
"alert_condition": "FAIL_ONLY",
}
response = client.post("/api/validation/tasks", json=payload)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Task"
assert data["id"] == "task-1"
ValidationTaskResponse(**data)
mock_all_deps["task_service"].create_task.assert_called_once()
# #endregion test_create_task_success
# #region test_create_task_missing_fields [C:2] [TYPE Function]
# @BRIEF POST with missing required fields returns 422.
def test_create_task_missing_fields(mock_all_deps):
# Empty payload
response = client.post("/api/validation/tasks", json={})
assert response.status_code == 422
errors = response.json()
assert "detail" in errors
# Missing provider_id
response = client.post("/api/validation/tasks", json={
"name": "Test", "environment_id": "env-1", "dashboard_ids": ["dash-1"],
})
assert response.status_code == 422
# Missing dashboard_ids
response = client.post("/api/validation/tasks", json={
"name": "Test", "environment_id": "env-1", "provider_id": "provider-1",
})
assert response.status_code == 422
# #endregion test_create_task_missing_fields
# #region test_create_task_invalid_provider [C:2] [TYPE Function]
# @BRIEF POST with non-multimodal provider returns 422.
def test_create_task_invalid_provider(mock_all_deps):
mock_all_deps["task_service"].create_task.side_effect = ValueError(
"LLM provider 'GPT-3.5' is not multimodal"
)
payload = {
"name": "Test", "environment_id": "env-1",
"dashboard_ids": ["dash-1"], "provider_id": "provider-2",
}
response = client.post("/api/validation/tasks", json=payload)
assert response.status_code == 422
assert "not multimodal" in response.json()["detail"]
# #endregion test_create_task_invalid_provider
# #region test_get_task_success [C:2] [TYPE Function]
# @BRIEF GET /validation/tasks/{id} returns task with recent_runs.
def test_get_task_success(mock_all_deps):
mock_all_deps["task_service"].get_task.return_value = _make_task_response()
mock_all_deps["run_service"].list_runs.return_value = (0, [])
response = client.get("/api/validation/tasks/task-1")
assert response.status_code == 200
data = response.json()
assert "task" in data
assert "recent_runs" in data
assert data["task"]["name"] == "Test Task"
mock_all_deps["task_service"].get_task.assert_called_with("task-1")
# #endregion test_get_task_success
# #region test_get_task_not_found [C:2] [TYPE Function]
# @BRIEF GET for nonexistent task returns 404.
def test_get_task_not_found(mock_all_deps):
mock_all_deps["task_service"].get_task.side_effect = ValueError("not found")
response = client.get("/api/validation/tasks/nonexistent")
assert response.status_code == 404
# #endregion test_get_task_not_found
# #region test_update_task_success [C:2] [TYPE Function]
# @BRIEF PUT /validation/tasks/{id} updates task and returns updated object.
def test_update_task_success(mock_all_deps):
updated = _make_task_response(name="Updated Name", is_active=False)
mock_all_deps["task_service"].update_task.return_value = updated
payload = {"name": "Updated Name", "is_active": False}
response = client.put("/api/validation/tasks/task-1", json=payload)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
assert data["is_active"] is False
ValidationTaskResponse(**data)
mock_all_deps["task_service"].update_task.assert_called_once()
# #endregion test_update_task_success
# #region test_update_task_not_found [C:2] [TYPE Function]
# @BRIEF PUT for nonexistent task returns 404.
def test_update_task_not_found(mock_all_deps):
mock_all_deps["task_service"].update_task.side_effect = ValueError("not found")
payload = {"name": "Nope"}
response = client.put("/api/validation/tasks/nonexistent", json=payload)
assert response.status_code == 404
# #endregion test_update_task_not_found
# #region test_delete_task_success [C:2] [TYPE Function]
# @BRIEF DELETE /validation/tasks/{id} returns 204.
def test_delete_task_success(mock_all_deps):
mock_all_deps["task_service"].delete_task.return_value = None
response = client.delete("/api/validation/tasks/task-1")
assert response.status_code == 204
mock_all_deps["task_service"].delete_task.assert_called_with("task-1", delete_runs=False)
# #endregion test_delete_task_success
# #region test_delete_task_not_found [C:2] [TYPE Function]
# @BRIEF DELETE for nonexistent task returns 404.
def test_delete_task_not_found(mock_all_deps):
mock_all_deps["task_service"].delete_task.side_effect = ValueError("not found")
response = client.delete("/api/validation/tasks/nonexistent")
assert response.status_code == 404
# #endregion test_delete_task_not_found
# #region test_delete_task_with_runs_flag [C:2] [TYPE Function]
# @BRIEF DELETE with delete_runs=true passes query param to service.
def test_delete_task_with_runs_flag(mock_all_deps):
mock_all_deps["task_service"].delete_task.return_value = None
response = client.delete("/api/validation/tasks/task-1?delete_runs=true")
assert response.status_code == 204
mock_all_deps["task_service"].delete_task.assert_called_with("task-1", delete_runs=True)
# #endregion test_delete_task_with_runs_flag
# #region test_trigger_run_success [C:2] [TYPE Function]
# @BRIEF POST /validation/tasks/{id}/run spawns a validation task.
def test_trigger_run_success(mock_all_deps):
mock_all_deps["task_service"].trigger_run = AsyncMock(return_value="spawned-1")
mock_all_deps["task_manager"].create_task = AsyncMock()
response = client.post("/api/validation/tasks/task-1/run")
assert response.status_code == 200
data = response.json()
assert data["task_id"] == "task-1"
assert data["spawned_task_id"] == "spawned-1"
assert data["status"] == "PENDING"
TriggerRunResponse(**data)
# #endregion test_trigger_run_success
# #region test_trigger_run_not_found [C:2] [TYPE Function]
# @BRIEF POST for nonexistent task returns 422.
def test_trigger_run_not_found(mock_all_deps):
mock_all_deps["task_service"].trigger_run = AsyncMock(
side_effect=ValueError("Validation task 'nonexistent' not found")
)
response = client.post("/api/validation/tasks/nonexistent/run")
assert response.status_code == 422
assert "not found" in response.json()["detail"]
# #endregion test_trigger_run_not_found
# #region test_trigger_run_no_dashboards [C:2] [TYPE Function]
# @BRIEF Trigger fails with 422 when task has no dashboard IDs.
def test_trigger_run_no_dashboards(mock_all_deps):
mock_all_deps["task_service"].trigger_run = AsyncMock(
side_effect=ValueError("has no dashboard IDs configured")
)
response = client.post("/api/validation/tasks/task-1/run")
assert response.status_code == 422
assert "no dashboard IDs" in response.json()["detail"]
# #endregion test_trigger_run_no_dashboards
# ── Run History Tests ──────────────────────────────────────────────────
# #region test_list_runs_success [C:2] [TYPE Function]
# @BRIEF GET /validation/runs returns paginated run list.
def test_list_runs_success(mock_all_deps):
run = _make_run_response()
mock_all_deps["run_service"].list_runs.return_value = (1, [run])
response = client.get("/api/validation/runs")
assert response.status_code == 200
data = response.json()
assert data["items"][0]["id"] == "run-1"
assert data["total"] == 1
ValidationRunListResponse(**data)
# #endregion test_list_runs_success
# #region test_list_runs_filters [C:2] [TYPE Function]
# @BRIEF All 6 filter query params are accepted and forwarded to service.
def test_list_runs_filters(mock_all_deps):
mock_all_deps["run_service"].list_runs.return_value = (0, [])
for query in [
"?task_id=task-1",
"?dashboard_id=dash-1",
"?environment_id=env-1",
"?status=PASS",
"?date_from=2026-01-01",
"?date_to=2026-12-31",
"?status=PASS&dashboard_id=dash-1&date_from=2026-01-01",
]:
response = client.get(f"/api/validation/runs{query}")
assert response.status_code == 200, f"Filter query failed: {query}"
# #endregion test_list_runs_filters
# #region test_list_runs_pagination [C:2] [TYPE Function]
# @BRIEF Pagination boundary checks on runs list.
def test_list_runs_pagination(mock_all_deps):
response = client.get("/api/validation/runs?page=0")
assert response.status_code == 422
response = client.get("/api/validation/runs?page_size=101")
assert response.status_code == 422
# #endregion test_list_runs_pagination
# #region test_list_runs_invalid_status [C:2] [TYPE Function]
# @BRIEF Status is a free string; any value passes through to service.
def test_list_runs_invalid_status(mock_all_deps):
mock_all_deps["run_service"].list_runs.return_value = (0, [])
response = client.get("/api/validation/runs?status=INVALID_STATUS_XYZ")
assert response.status_code == 200
call_kwargs = mock_all_deps["run_service"].list_runs.call_args[1]
assert call_kwargs.get("status") == "INVALID_STATUS_XYZ"
# #endregion test_list_runs_invalid_status
# #region test_get_run_detail_success [C:2] [TYPE Function]
# @BRIEF GET /validation/runs/{id} returns full detail with issues and raw_response.
def test_get_run_detail_success(mock_all_deps):
mock_all_deps["run_service"].get_run_detail.return_value = ValidationRunDetailResponse(
id="run-1",
task_id="task-1",
dashboard_id="dash-1",
environment_id="env-1",
status="FAIL",
summary="Charts failed to render",
timestamp=_make_now(),
screenshot_path="/tmp/screenshots/run-1.png",
issues=[{"severity": "HIGH", "message": "Chart timeout"}],
raw_response='{"status": "error"}',
task_name="Test Task",
)
response = client.get("/api/validation/runs/run-1")
assert response.status_code == 200
data = response.json()
assert data["status"] == "FAIL"
assert len(data["issues"]) == 1
assert data["raw_response"] is not None
ValidationRunDetailResponse(**data)
# #endregion test_get_run_detail_success
# #region test_get_run_detail_not_found [C:2] [TYPE Function]
# @BRIEF GET for nonexistent run returns 404.
def test_get_run_detail_not_found(mock_all_deps):
mock_all_deps["run_service"].get_run_detail.side_effect = ValueError("not found")
response = client.get("/api/validation/runs/nonexistent")
assert response.status_code == 404
# #endregion test_get_run_detail_not_found
# #region test_delete_run_success [C:2] [TYPE Function]
# @BRIEF DELETE /validation/runs/{id} returns 204.
def test_delete_run_success(mock_all_deps):
mock_all_deps["run_service"].delete_run.return_value = True
response = client.delete("/api/validation/runs/run-1")
assert response.status_code == 204
mock_all_deps["run_service"].delete_run.assert_called_with("run-1")
# #endregion test_delete_run_success
# #region test_delete_run_not_found [C:2] [TYPE Function]
# @BRIEF DELETE for nonexistent run returns 404.
def test_delete_run_not_found(mock_all_deps):
mock_all_deps["run_service"].delete_run.return_value = False
response = client.delete("/api/validation/runs/nonexistent")
assert response.status_code == 404
# #endregion test_delete_run_not_found
# ── Auth / Permission Tests ────────────────────────────────────────────
# #region test_endpoints_require_authentication [C:2] [TYPE Function]
# @BRIEF Without current_user dependency, all 9 endpoints return 401.
def test_endpoints_require_authentication(mock_all_deps):
app.dependency_overrides.pop(get_current_user, None)
endpoints = [
("GET", "/api/validation/tasks"),
("POST", "/api/validation/tasks"),
("GET", "/api/validation/tasks/task-1"),
("PUT", "/api/validation/tasks/task-1"),
("DELETE", "/api/validation/tasks/task-1"),
("POST", "/api/validation/tasks/task-1/run"),
("GET", "/api/validation/runs"),
("GET", "/api/validation/runs/run-1"),
("DELETE", "/api/validation/runs/run-1"),
]
for method, path in endpoints:
response = client.request(method, path)
assert response.status_code == 401, f"{method} {path} expected 401 got {response.status_code}"
# #endregion test_endpoints_require_authentication
# #region test_permission_denied_non_admin [C:2] [TYPE Function]
# @BRIEF A non-admin user without validation.* permissions receives 403.
def test_permission_denied_non_admin(mock_all_deps):
"""Remove Admin role so has_permission raises 403."""
mock_user.roles = [] # No Admin, no permissions
response = client.get("/api/validation/tasks")
assert response.status_code == 403
# #endregion test_permission_denied_non_admin
# #region test_permission_denied_all_actions [C:2] [TYPE Function]
# @BRIEF Each action (VIEW, CREATE, EDIT, DELETE, EXECUTE) denied for non-admin.
def test_permission_denied_all_actions(mock_all_deps):
mock_user.roles = []
action_endpoints = [
("GET", "/api/validation/tasks"),
("POST", "/api/validation/tasks"),
("GET", "/api/validation/tasks/task-1"),
("PUT", "/api/validation/tasks/task-1"),
("DELETE", "/api/validation/tasks/task-1"),
("POST", "/api/validation/tasks/task-1/run"),
("GET", "/api/validation/runs"),
("GET", "/api/validation/runs/run-1"),
("DELETE", "/api/validation/runs/run-1"),
]
for method, path in action_endpoints:
response = client.request(method, path)
assert response.status_code == 403, f"{method} {path} expected 403 got {response.status_code}"
# #endregion test_permission_denied_all_actions
# #region test_delete_task_runs_default_false [C:2] [TYPE Function]
# @BRIEF delete_runs query param defaults to False.
def test_delete_task_runs_default_false(mock_all_deps):
mock_all_deps["task_service"].delete_task.return_value = None
response = client.delete("/api/validation/tasks/task-1")
assert response.status_code == 204
mock_all_deps["task_service"].delete_task.assert_called_with("task-1", delete_runs=False)
# #endregion test_delete_task_runs_default_false
# #endregion ValidationApiTests

View File

@@ -44,6 +44,7 @@ from .api.routes import (
storage,
tasks,
translate,
validation,
)
from .core.auth.security import get_password_hash
from .core.cot_logger import seed_trace_id
@@ -217,6 +218,7 @@ app.add_middleware(TraceContextMiddleware)
# @RELATION DEPENDS_ON -> [ReportsRouter]
# @RELATION DEPENDS_ON -> [LlmRoutes]
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api]
# @RELATION DEPENDS_ON -> [ValidationRoutes]
# Include API routes
app.include_router(auth.router)
app.include_router(admin.router)
@@ -239,6 +241,7 @@ app.include_router(profile.router)
app.include_router(dataset_review.router)
app.include_router(health.router)
app.include_router(translate.router)
app.include_router(validation.router, prefix="/api/validation", tags=["Validation"])
# #endregion API_Routes
# #region api.include_routers [C:1] [TYPE Action] [SEMANTICS routes, registration, api]
# @BRIEF Registers all API routers with the FastAPI application.

View File

@@ -16,6 +16,7 @@ def generate_uuid():
# #region ValidationPolicy [TYPE Class]
# @BRIEF Defines a scheduled rule for validating a group of dashboards within an execution window.
# @RELATION DEPENDS_ON -> [LLMProvider]
class ValidationPolicy(Base):
__tablename__ = "validation_policies"
@@ -24,6 +25,7 @@ class ValidationPolicy(Base):
environment_id = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
dashboard_ids = Column(JSON, nullable=False) # Array of dashboard IDs
provider_id = Column(String, nullable=True) # Reference to LLMProvider.id
schedule_days = Column(JSON, nullable=False) # Array of integers (0-6)
window_start = Column(Time, nullable=False)
window_end = Column(Time, nullable=False)
@@ -57,6 +59,7 @@ class ValidationRecord(Base):
id = Column(String, primary_key=True, default=generate_uuid)
task_id = Column(String, nullable=True, index=True) # Reference to TaskRecord
policy_id = Column(String, nullable=True, index=True) # Reference to ValidationPolicy.id
dashboard_id = Column(String, nullable=False, index=True)
environment_id = Column(String, nullable=True, index=True)
timestamp = Column(DateTime, default=datetime.utcnow)

View File

@@ -286,6 +286,7 @@ class DashboardValidationPlugin(PluginBase):
db_record = ValidationRecord(
task_id=context.task_id if context else None,
policy_id=params.get("policy_id"),
dashboard_id=validation_result.dashboard_id,
environment_id=env_id,
status=validation_result.status.value,

View File

@@ -13,6 +13,8 @@ import base64
import io
import json
import os
import shutil
import tempfile
from typing import Any
from urllib.parse import urlsplit
@@ -282,406 +284,508 @@ class ScreenshotService:
return await page.goto(url, wait_until=fallback_wait_until, timeout=timeout)
# endregion ScreenshotService._goto_resilient
# region ScreenshotService.capture_dashboard [TYPE Function]
# region ScreenshotService._wait_for_charts_stabilized [TYPE Function] [C:2]
# @BRIEF Wait until chart elements have non-zero dimensions, with polling.
# @PRE page is a valid Playwright page.
# @POST Waits for chart stabilization or raises on timeout (handled internally).
# @RATIONALE Polls for actual chart rendering dimensions rather than using a fixed delay — charts may load at different speeds depending on dashboard complexity and network conditions.
# @REJECTED Fixed sleep-based wait rejected — would either waste time (too long) or produce blank screenshots (too short); polling for actual canvas/svg dimensions is more reliable.
async def _wait_for_charts_stabilized(self, page, timeout_ms: int = 15000):
"""Wait until chart elements have non-zero dimensions, with polling."""
# Short initial delay for rendering pipeline to start
await asyncio.sleep(0.5)
try:
await page.wait_for_function("""() => {
const charts = document.querySelectorAll('.chart-container canvas, .slice_container svg, .grid-content canvas');
if (charts.length === 0) return true;
return Array.from(charts).some(c => {
if (c.tagName === 'CANVAS') return c.width > 10 && c.height > 10;
if (c.tagName === 'svg') {
const bbox = c.getBoundingClientRect();
return bbox.width > 10 && bbox.height > 10;
}
return false;
});
}""", timeout=timeout_ms)
except Exception:
logger.warning("[ScreenshotService] Chart stabilization wait timed out, proceeding anyway")
# endregion ScreenshotService._wait_for_charts_stabilized
# region ScreenshotService._wait_for_resize_rendered [TYPE Function] [C:2]
# @BRIEF Wait for charts to re-render after viewport resize.
# @PRE page is a valid Playwright page; chart_count_before contains pre-resize element counts.
# @POST Waits for chart content to return or timeout.
# @RATIONALE After viewport resize, Superset triggers lazy chart re-rendering — this function polls for chart elements to reappear before taking the screenshot.
# @REJECTED Single fixed wait after resize rejected — some dashboards re-render instantly while others take seconds; fixed wait is brittle across dashboard types.
async def _wait_for_resize_rendered(self, page, chart_count_before: dict, timeout_ms: int = 10000):
"""Wait for charts to re-render after viewport resize, with polling."""
try:
await page.wait_for_function("""(preCounts) => {
const currentCharts = document.querySelectorAll('.chart-container, .slice_container').length;
const currentCanvases = document.querySelectorAll('canvas').length;
const currentSvgs = document.querySelectorAll('.chart-container svg, .slice_container svg').length;
// At least one chart element must be present
return currentCharts > 0 && (currentCanvases > 0 || currentSvgs > 0);
}""", arg=chart_count_before, timeout=timeout_ms)
except Exception:
logger.warning("[ScreenshotService] Re-render wait timed out after viewport resize, proceeding anyway")
# endregion ScreenshotService._wait_for_resize_rendered
# region ScreenshotService._save_debug_screenshot [TYPE Function] [C:1]
# @BRIEF Save a debug screenshot to a temp directory for diagnostic purposes.
# @PRE debug_dir exists and is writable.
# @POST Returns the debug path or None on failure.
# @RATIONALE Uses tempfile.mkdtemp() to avoid accumulating .png files in production storage. Temp dir is cleaned up on success (rmtree) or preserved on failure for debugging.
# @REJECTED Old approach saved debug .png next to output path — accumulated _debug_failed_login.png, _preresize.png permanently in screenshots dir (F1). In-memory-only debug logging rejected — screenshot state is visual and cannot be captured in logs.
async def _save_debug_screenshot(self, page, debug_dir: str, suffix: str) -> str | None:
debug_path = os.path.join(debug_dir, suffix)
try:
await page.screenshot(path=debug_path)
return debug_path
except Exception:
return None
# endregion ScreenshotService._save_debug_screenshot
# region ScreenshotService.capture_dashboard [TYPE Function] [C:4]
# @PURPOSE Captures a full-page screenshot of a dashboard using Playwright and CDP.
# @PRE dashboard_id is a valid string, output_path is a writable path.
# @POST Returns True if screenshot is saved successfully.
# @SIDE_EFFECT Launches a browser, performs UI login, switches tabs, and writes a PNG file.
# @POST Returns True if screenshot is saved successfully. Debug temp dir cleaned up on success, preserved on failure.
# @SIDE_EFFECT Launches a browser, performs UI login, switches tabs, writes a PNG file; creates and cleans up temp debug directory.
# @UX_STATE [Navigating] -> Loading dashboard UI
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
# @UX_STATE [Capturing] -> Executing CDP screenshot
# @RATIONALE Uses tempfile.mkdtemp() for all debug screenshots; cleans up on success (shutil.rmtree), preserves on failure for diagnostic analysis. Pre-resize screenshot is saved to temp dir and auto-cleaned on success.
# @REJECTED Old approach saved debug .png files alongside output — accumulated permanently (F1). In-memory-only logging rejected — visual state cannot be captured in text.
async def capture_dashboard(self, dashboard_id: str, output_path: str) -> bool:
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-infobars",
"--no-sandbox"
]
)
# Set a realistic user agent to avoid 403 Forbidden from OpenResty/WAF
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
# Construct base UI URL from environment (strip /api/v1 suffix)
base_ui_url = self.env.url.rstrip("/")
if base_ui_url.endswith("/api/v1"):
base_ui_url = base_ui_url[:-len("/api/v1")]
debug_dir = tempfile.mkdtemp(prefix="ss_screenshot_debug_")
success = False
try:
# -- begin original body --
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-infobars",
"--no-sandbox"
]
)
# Set a realistic user agent to avoid 403 Forbidden from OpenResty/WAF
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
# Construct base UI URL from environment (strip /api/v1 suffix)
base_ui_url = self.env.url.rstrip("/")
if base_ui_url.endswith("/api/v1"):
base_ui_url = base_ui_url[:-len("/api/v1")]
# Create browser context with realistic headers
context = await browser.new_context(
viewport={'width': 1280, 'height': 720},
user_agent=user_agent,
extra_http_headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1"
# Create browser context with realistic headers
context = await browser.new_context(
viewport={'width': 1280, 'height': 720},
user_agent=user_agent,
extra_http_headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1"
}
)
logger.info("Browser context created successfully")
page = await context.new_page()
# Bypass navigator.webdriver detection
await page.add_init_script("delete Object.getPrototypeOf(navigator).webdriver")
# 1. Navigate to login page and authenticate
login_url = f"{base_ui_url.rstrip('/')}/login/"
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
response = await self._goto_resilient(
page,
login_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
if response:
logger.info(f"[DEBUG] Login page response status: {response.status}")
# Wait for login form to be ready
await page.wait_for_load_state("domcontentloaded")
# More exhaustive list of selectors for various Superset versions/themes
selectors = {
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
"password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]', 'input[type="password"]'],
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
}
)
logger.info("Browser context created successfully")
logger.info("[DEBUG] Attempting to find login form elements...")
page = await context.new_page()
# Bypass navigator.webdriver detection
await page.add_init_script("delete Object.getPrototypeOf(navigator).webdriver")
try:
used_direct_form_login = False
# Find and fill username
username_locator = await self._find_login_field_locator(page, "username")
# 1. Navigate to login page and authenticate
login_url = f"{base_ui_url.rstrip('/')}/login/"
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
if not username_locator:
roots = self._iter_login_roots(page)
logger.info(f"[DEBUG] Found {len(roots)} login roots including child frames")
for root_index, root in enumerate(roots[:5]):
all_inputs = await root.locator('input').all()
logger.info(f"[DEBUG] Root {root_index}: found {len(all_inputs)} input fields")
for i, inp in enumerate(all_inputs[:5]): # Log first 5
inp_type = await inp.get_attribute('type')
inp_name = await inp.get_attribute('name')
inp_id = await inp.get_attribute('id')
logger.info(f"[DEBUG] Root {root_index} input {i}: type={inp_type}, name={inp_name}, id={inp_id}")
used_direct_form_login = await self._submit_login_via_form_post(page, login_url)
if not used_direct_form_login:
raise RuntimeError("Could not find username input field on login page")
username_locator = None
response = await self._goto_resilient(
page,
login_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
if response:
logger.info(f"[DEBUG] Login page response status: {response.status}")
if username_locator is not None:
logger.info("[DEBUG] Filling username field")
await username_locator.fill(self.env.username)
# Wait for login form to be ready
await page.wait_for_load_state("domcontentloaded")
# Find and fill password
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
# More exhaustive list of selectors for various Superset versions/themes
selectors = {
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
"password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]', 'input[type="password"]'],
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
}
logger.info("[DEBUG] Attempting to find login form elements...")
if username_locator is not None and not password_locator:
raise RuntimeError("Could not find password input field on login page")
try:
used_direct_form_login = False
# Find and fill username
username_locator = await self._find_login_field_locator(page, "username")
if password_locator is not None:
logger.info("[DEBUG] Filling password field")
await password_locator.fill(self.env.password)
if not username_locator:
roots = self._iter_login_roots(page)
logger.info(f"[DEBUG] Found {len(roots)} login roots including child frames")
for root_index, root in enumerate(roots[:5]):
all_inputs = await root.locator('input').all()
logger.info(f"[DEBUG] Root {root_index}: found {len(all_inputs)} input fields")
for i, inp in enumerate(all_inputs[:5]): # Log first 5
inp_type = await inp.get_attribute('type')
inp_name = await inp.get_attribute('name')
inp_id = await inp.get_attribute('id')
logger.info(f"[DEBUG] Root {root_index} input {i}: type={inp_type}, name={inp_name}, id={inp_id}")
used_direct_form_login = await self._submit_login_via_form_post(page, login_url)
# Click submit
submit_locator = await self._find_submit_locator(page) if username_locator is not None else None
if username_locator is not None and not submit_locator:
raise RuntimeError("Could not find submit button on login page")
if submit_locator is not None:
logger.info("[DEBUG] Clicking submit button")
await submit_locator.click()
# Wait for navigation after login
if not used_direct_form_login:
raise RuntimeError("Could not find username input field on login page")
username_locator = None
try:
await page.wait_for_load_state("load", timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
except Exception as load_wait_error:
logger.warning(f"[DEBUG] Login post-submit load wait timed out: {load_wait_error}")
if username_locator is not None:
logger.info("[DEBUG] Filling username field")
await username_locator.fill(self.env.username)
# Check if login was successful
if not used_direct_form_login and "/login" in page.url:
# Check for error messages on page
error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
logger.error(f"[DEBUG] Login failed. Still on login page. Error: {error_msg}")
debug_path = await self._save_debug_screenshot(page, debug_dir, "failed_login.png")
raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
# Find and fill password
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
if username_locator is not None and not password_locator:
raise RuntimeError("Could not find password input field on login page")
# Check cookies after successful login
page_cookies = await context.cookies()
logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
for c in page_cookies:
logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
if password_locator is not None:
logger.info("[DEBUG] Filling password field")
await password_locator.fill(self.env.password)
except Exception as e:
page_title = await page.title()
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {e!s}")
debug_path = await self._save_debug_screenshot(page, debug_dir, "failed_login.png")
raise RuntimeError(f"Login failed: {e!s}. Debug screenshot saved to {debug_path}")
# Click submit
submit_locator = await self._find_submit_locator(page) if username_locator is not None else None
# 2. Navigate to dashboard
# @UX_STATE [Navigating] -> Loading dashboard UI
dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
if username_locator is not None and not submit_locator:
raise RuntimeError("Could not find submit button on login page")
if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
dashboard_url = dashboard_url.replace("http://", "https://")
if submit_locator is not None:
logger.info("[DEBUG] Clicking submit button")
await submit_locator.click()
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
# Wait for navigation after login
if not used_direct_form_login:
try:
await page.wait_for_load_state("load", timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
except Exception as load_wait_error:
logger.warning(f"[DEBUG] Login post-submit load wait timed out: {load_wait_error}")
# Check if login was successful
if not used_direct_form_login and "/login" in page.url:
# Check for error messages on page
error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
logger.error(f"[DEBUG] Login failed. Still on login page. Error: {error_msg}")
debug_path = output_path.replace(".png", "_debug_failed_login.png")
await page.screenshot(path=debug_path)
raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
# Check cookies after successful login
page_cookies = await context.cookies()
logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
for c in page_cookies:
logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
except Exception as e:
page_title = await page.title()
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {e!s}")
debug_path = output_path.replace(".png", "_debug_failed_login.png")
await page.screenshot(path=debug_path)
raise RuntimeError(f"Login failed: {e!s}. Debug screenshot saved to {debug_path}")
# 2. Navigate to dashboard
# @UX_STATE [Navigating] -> Loading dashboard UI
dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
dashboard_url = dashboard_url.replace("http://", "https://")
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
# Dashboard pages can keep polling/network activity open indefinitely.
response = await self._goto_resilient(
page,
dashboard_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
if response:
logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
if "/login" in page.url:
debug_path = output_path.replace(".png", "_debug_failed_dashboard_auth.png")
await page.screenshot(path=debug_path)
raise RuntimeError(
f"Dashboard navigation redirected to login page after authentication. Debug screenshot saved to {debug_path}"
# Dashboard pages can keep polling/network activity open indefinitely.
response = await self._goto_resilient(
page,
dashboard_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
try:
# Wait for the dashboard grid to be present
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
logger.info("[DEBUG] Dashboard container loaded")
if response:
logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
if "/login" in page.url:
debug_path = await self._save_debug_screenshot(page, debug_dir, "failed_dashboard_auth.png")
raise RuntimeError(
f"Dashboard navigation redirected to login page after authentication. Debug screenshot saved to {debug_path}"
)
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
# We wait until loading indicators disappear or a timeout occurs
try:
# Wait for loading indicators to disappear
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Loading indicators hidden")
except Exception:
logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")
# Wait for the dashboard grid to be present
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
logger.info("[DEBUG] Dashboard container loaded")
# Wait for charts to actually render their content (e.g., ECharts, NVD3)
# We look for common chart containers that should have content
try:
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Chart content detected")
except Exception:
logger.warning("[DEBUG] Timeout waiting for chart content")
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
# We wait until loading indicators disappear or a timeout occurs
try:
# Wait for loading indicators to disappear
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Loading indicators hidden")
except Exception:
logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")
# Additional check: wait for all chart containers to have non-empty content
logger.info("[DEBUG] Waiting for all charts to have rendered content...")
await page.wait_for_function("""() => {
const charts = document.querySelectorAll('.chart-container, .slice_container');
if (charts.length === 0) return true; // No charts to wait for
# Wait for charts to actually render their content (e.g., ECharts, NVD3)
# We look for common chart containers that should have content
try:
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Chart content detected")
except Exception:
logger.warning("[DEBUG] Timeout waiting for chart content")
# Additional check: wait for all chart containers to have non-empty content
logger.info("[DEBUG] Waiting for all charts to have rendered content...")
await page.wait_for_function("""() => {
const charts = document.querySelectorAll('.chart-container, .slice_container');
if (charts.length === 0) return true; // No charts to wait for
// Check if all charts have rendered content (canvas, svg, or non-empty div)
return Array.from(charts).every(chart => {
const hasCanvas = chart.querySelector('canvas') !== null;
const hasSvg = chart.querySelector('svg') !== null;
const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
return hasCanvas || hasSvg || hasContent;
});
}""", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] All charts have rendered content")
// Check if all charts have rendered content (canvas, svg, or non-empty div)
return Array.from(charts).every(chart => {
const hasCanvas = chart.querySelector('canvas') !== null;
const hasSvg = chart.querySelector('svg') !== null;
const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
return hasCanvas || hasSvg || hasContent;
});
}""", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] All charts have rendered content")
# Scroll to bottom and back to top to trigger lazy loading of all charts
logger.info("[DEBUG] Scrolling to trigger lazy loading...")
await page.evaluate("""async () => {
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 0; i < document.body.scrollHeight; i += 500) {
window.scrollTo(0, i);
await delay(100);
}
window.scrollTo(0, 0);
await delay(500);
}""")
except Exception as e:
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
# Final stabilization delay - increased for complex dashboards
logger.info("[DEBUG] Final stabilization delay...")
await asyncio.sleep(15)
# Logic to handle tabs and full-page capture
try:
# 1. Handle Tabs (Recursive switching)
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
processed_tabs = set()
async def switch_tabs(depth=0):
if depth > 3:
return # Limit recursion depth
tab_selectors = [
'.ant-tabs-nav-list .ant-tabs-tab',
'.dashboard-component-tabs .ant-tabs-tab',
'[data-test="dashboard-component-tabs"] .ant-tabs-tab'
]
found_tabs = []
for selector in tab_selectors:
found_tabs = await page.locator(selector).all()
if found_tabs:
break
if found_tabs:
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
for i, tab in enumerate(found_tabs):
try:
tab_text = (await tab.inner_text()).strip()
tab_id = f"{depth}_{i}_{tab_text}"
if tab_id in processed_tabs:
continue
if await tab.is_visible():
logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
processed_tabs.add(tab_id)
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
if not is_active:
await tab.click()
await asyncio.sleep(2) # Wait for content to render
await switch_tabs(depth + 1)
except Exception as tab_e:
logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
try:
first_tab = found_tabs[0]
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
await first_tab.click()
await asyncio.sleep(1)
except Exception:
pass
await switch_tabs()
# 2. Calculate full height for screenshot
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
full_height = await page.evaluate("""() => {
const body = document.body;
const html = document.documentElement;
const dashboardContent = document.querySelector('.dashboard-content');
return Math.max(
body.scrollHeight, body.offsetHeight,
html.clientHeight, html.scrollHeight, html.offsetHeight,
dashboardContent ? dashboardContent.scrollHeight + 100 : 0
);
}""")
logger.info(f"[DEBUG] Calculated full height: {full_height}")
# DIAGNOSTIC: Count chart elements before resize
chart_count_before = await page.evaluate("""() => {
return {
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
canvasElements: document.querySelectorAll('canvas').length,
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
pre_resize_path = output_path.replace(".png", "_preresize.png")
try:
await page.screenshot(path=pre_resize_path, full_page=False, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
import os
pre_resize_size = os.path.getsize(pre_resize_path) if os.path.exists(pre_resize_path) else 0
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
except Exception as pre_e:
logger.warning(f"[DIAGNOSTIC] Failed to capture pre-resize screenshot: {pre_e}")
logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
await page.set_viewport_size({"width": 1920, "height": int(full_height)})
# DIAGNOSTIC: Increased wait time and log timing
logger.info("[DIAGNOSTIC] Waiting 10 seconds after viewport resize for re-render...")
await asyncio.sleep(10)
logger.info("[DIAGNOSTIC] Wait completed")
# DIAGNOSTIC: Count chart elements after resize and wait
chart_count_after = await page.evaluate("""() => {
return {
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
canvasElements: document.querySelectorAll('canvas').length,
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
# DIAGNOSTIC: Check if any charts have error states
chart_errors = await page.evaluate("""() => {
const errors = [];
document.querySelectorAll('.chart-container, .slice_container').forEach((chart, i) => {
const errorEl = chart.querySelector('.error, .alert-danger, .ant-alert-error');
if (errorEl) {
errors.push({index: i, text: errorEl.innerText.substring(0, 100)});
# Scroll to bottom and back to top to trigger lazy loading of all charts
logger.info("[DEBUG] Scrolling to trigger lazy loading...")
await page.evaluate("""async () => {
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 0; i < document.body.scrollHeight; i += 500) {
window.scrollTo(0, i);
await delay(100);
}
});
return errors;
}""")
if chart_errors:
logger.warning(f"[DIAGNOSTIC] Charts with error states detected: {chart_errors}")
else:
logger.info("[DIAGNOSTIC] No chart error states detected")
window.scrollTo(0, 0);
await delay(500);
}""")
# 3. Take screenshot using CDP to bypass Playwright's font loading wait
# @UX_STATE [Capturing] -> Executing CDP screenshot
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
cdp = await page.context.new_cdp_session(page)
except Exception as e:
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
screenshot_data = await cdp.send("Page.captureScreenshot", {
"format": "png",
"fromSurface": True,
"captureBeyondViewport": True
})
# Final stabilization delay - wait for charts to stabilize
logger.info("[DEBUG] Waiting for charts to stabilize...")
await self._wait_for_charts_stabilized(page)
image_data = base64.b64decode(screenshot_data["data"])
with open(output_path, 'wb') as f:
f.write(image_data)
# DIAGNOSTIC: Verify screenshot file
import os
final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
# DIAGNOSTIC: Get image dimensions
# Logic to handle tabs and full-page capture
try:
with Image.open(output_path) as final_img:
logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
except Exception as img_err:
logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
async with asyncio.timeout(SCREENSHOT_SERVICE_TIMEOUT_MS / 1000):
# 1. Handle Tabs (Recursive switching)
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
processed_tabs = set()
logger.info(f"Full-page screenshot saved to {output_path} (via CDP)")
except Exception as e:
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
try:
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
except Exception as e2:
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
async def switch_tabs(depth=0):
if depth > 3:
return # Limit recursion depth
await browser.close()
return True
tab_selectors = [
'.ant-tabs-nav-list .ant-tabs-tab',
'.dashboard-component-tabs .ant-tabs-tab',
'[data-test="dashboard-component-tabs"] .ant-tabs-tab'
]
found_tabs = []
for selector in tab_selectors:
found_tabs = await page.locator(selector).all()
if found_tabs:
break
if found_tabs:
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
for i, tab in enumerate(found_tabs):
try:
tab_text = (await tab.inner_text()).strip()
tab_id = f"{depth}_{i}_{tab_text}"
if tab_id in processed_tabs:
continue
if await tab.is_visible():
logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
processed_tabs.add(tab_id)
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
if not is_active:
await tab.click()
# Wait for chart content to render in the newly active tab
try:
await page.wait_for_function("""() => {
const activeTab = document.querySelector('.ant-tabs-tab-active');
if (!activeTab) return true;
const tabPane = activeTab.closest('.ant-tabs')?.querySelector('.ant-tabs-content-holder');
if (!tabPane) return true;
const charts = tabPane.querySelectorAll('canvas, svg');
return charts.length > 0;
}""", timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
except Exception:
logger.warning(f"[TabSwitching] Content verification timed out for tab: {tab_text}")
await switch_tabs(depth + 1)
except Exception as tab_e:
logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
try:
first_tab = found_tabs[0]
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
await first_tab.click()
try:
await page.wait_for_function("""() => {
const activeTab = document.querySelector('.ant-tabs-tab-active');
if (!activeTab) return true;
const tabPane = activeTab.closest('.ant-tabs')?.querySelector('.ant-tabs-content-holder');
if (!tabPane) return true;
const charts = tabPane.querySelectorAll('canvas, svg');
return charts.length > 0;
}""", timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
except Exception:
logger.warning("[TabSwitching] Content verification timed out for first tab")
except Exception:
pass
await switch_tabs()
# 2. Calculate full height for screenshot
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
full_height = await page.evaluate("""() => {
const body = document.body;
const html = document.documentElement;
const dashboardContent = document.querySelector('.dashboard-content');
return Math.max(
body.scrollHeight, body.offsetHeight,
html.clientHeight, html.scrollHeight, html.offsetHeight,
dashboardContent ? dashboardContent.scrollHeight + 100 : 0
);
}""")
logger.info(f"[DEBUG] Calculated full height: {full_height}")
# DIAGNOSTIC: Count chart elements before resize
chart_count_before = await page.evaluate("""() => {
return {
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
canvasElements: document.querySelectorAll('canvas').length,
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
pre_resize_path = await self._save_debug_screenshot(page, debug_dir, "preresize.png")
if pre_resize_path:
pre_resize_size = os.path.getsize(pre_resize_path) if os.path.exists(pre_resize_path) else 0
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
else:
logger.warning("[DIAGNOSTIC] Failed to capture pre-resize screenshot")
logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
await page.set_viewport_size({"width": 1920, "height": int(full_height)})
# Wait for charts to re-render after viewport resize
logger.info("[DIAGNOSTIC] Waiting for re-render after viewport resize...")
await self._wait_for_resize_rendered(page, chart_count_before)
logger.info("[DIAGNOSTIC] Re-render wait completed")
# DIAGNOSTIC: Count chart elements after resize and wait
chart_count_after = await page.evaluate("""() => {
return {
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
canvasElements: document.querySelectorAll('canvas').length,
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
# DIAGNOSTIC: Check if any charts have error states
chart_errors = await page.evaluate("""() => {
const errors = [];
document.querySelectorAll('.chart-container, .slice_container').forEach((chart, i) => {
const errorEl = chart.querySelector('.error, .alert-danger, .ant-alert-error');
if (errorEl) {
errors.push({index: i, text: errorEl.innerText.substring(0, 100)});
}
});
return errors;
}""")
if chart_errors:
logger.warning(f"[DIAGNOSTIC] Charts with error states detected: {chart_errors}")
else:
logger.info("[DIAGNOSTIC] No chart error states detected")
# 3. Take screenshot using CDP to bypass Playwright's font loading wait
# @UX_STATE [Capturing] -> Executing CDP screenshot
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
cdp_success = False
try:
cdp = await page.context.new_cdp_session(page)
screenshot_data = await cdp.send("Page.captureScreenshot", {
"format": "png",
"fromSurface": True,
"captureBeyondViewport": True
})
image_data = base64.b64decode(screenshot_data["data"])
with open(output_path, 'wb') as f:
f.write(image_data)
logger.info("Full-page screenshot saved via CDP")
cdp_success = True
except Exception as cdp_err:
logger.warning(f"CDP screenshot failed: {cdp_err}. Falling back to Playwright full_page.")
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
logger.info("Full-page screenshot saved via Playwright fallback")
cdp_success = True
if cdp_success:
# DIAGNOSTIC: Verify screenshot file
final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
# DIAGNOSTIC: Get image dimensions
try:
with Image.open(output_path) as final_img:
logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
except Exception as img_err:
logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
except asyncio.TimeoutError:
logger.error(f"Screenshot capture timed out after {SCREENSHOT_SERVICE_TIMEOUT_MS}ms")
try:
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
logger.info("Emergency timeout screenshot saved")
except Exception:
pass
except Exception as e:
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
try:
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
except Exception as e2:
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
await browser.close()
success = True
return True
finally:
if success:
shutil.rmtree(debug_dir, ignore_errors=True)
else:
logger.info(f"Debug screenshots preserved in {debug_dir}")
# endregion ScreenshotService.capture_dashboard
# #endregion ScreenshotService
@@ -985,18 +1089,7 @@ class LLMClient:
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
}
# endregion LLMClient.analyze_dashboard
# #endregion LLMClient
# #endregion LLMAnalysisService
return await self.get_json_completion(messages)
except Exception as e:
logger.error(f"[analyze_dashboard] Failed to get analysis: {e!s}")
return {
"status": "UNKNOWN",
"summary": f"Failed to get response from LLM: {e!s}",
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
}
# endregion LLMClient.analyze_dashboard
# #endregion LLMClient
# #endregion LLMAnalysisService

View File

@@ -0,0 +1,139 @@
# #region ValidationSchemas [C:3] [TYPE Module] [SEMANTICS pydantic, validation, schema, task, run]
# @BRIEF Pydantic v2 schemas for validation task management and run history API serialization.
# @LAYER API
# @RELATION DEPENDS_ON -> pydantic
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# #region ValidationTaskCreate [C:1] [TYPE Class]
# @BRIEF Schema for creating a new validation task (policy).
class ValidationTaskCreate(BaseModel):
name: str = Field(..., description="Human-readable task name")
environment_id: str = Field(..., description="Target Superset environment ID")
dashboard_ids: list[str] = Field(..., description="List of dashboard IDs to validate (use single-element array for v1)")
provider_id: str = Field(..., description="LLM provider ID — must be multimodal")
schedule_days: list[int] | None = Field(None, description="Days of week (0-6, 0=Sunday). Omit or empty for manual-only.")
window_start: str | None = Field(None, description="Window start time, HH:MM format")
window_end: str | None = Field(None, description="Window end time, HH:MM format")
notify_owners: bool = Field(True, description="Notify dashboard owners on failure")
custom_channels: list[str] | None = Field(None, description="Additional notification channels")
alert_condition: str = Field("FAIL_ONLY", description="FAIL_ONLY, WARN_AND_FAIL, or ALWAYS")
is_active: bool = Field(True, description="Whether the task is active")
# #endregion ValidationTaskCreate
# #region ValidationTaskUpdate [C:1] [TYPE Class]
# @BRIEF Schema for updating an existing validation task (policy).
class ValidationTaskUpdate(BaseModel):
name: str | None = None
environment_id: str | None = None
dashboard_ids: list[str] | None = None
provider_id: str | None = None
schedule_days: list[int] | None = None
window_start: str | None = None
window_end: str | None = None
notify_owners: bool | None = None
custom_channels: list[str] | None = None
alert_condition: str | None = None
is_active: bool | None = None
# #endregion ValidationTaskUpdate
# #region ValidationTaskResponse [C:1] [TYPE Class]
# @BRIEF Schema for validation task API responses — includes last run status from join.
class ValidationTaskResponse(BaseModel):
id: str
name: str
environment_id: str
is_active: bool
dashboard_ids: list[str]
provider_id: str | None = None
schedule_days: list[int] | None = None
window_start: str | None = None
window_end: str | None = None
notify_owners: bool = True
custom_channels: list[str] | None = None
alert_condition: str = "FAIL_ONLY"
created_at: datetime
updated_at: datetime | None = None
last_run_status: str | None = None
last_run_at: datetime | None = None
class Config:
from_attributes = True
# #endregion ValidationTaskResponse
# #region ValidationTaskListResponse [C:1] [TYPE Class]
# @BRIEF Schema for paginated task list response.
class ValidationTaskListResponse(BaseModel):
items: list[ValidationTaskResponse] = []
total: int = 0
page: int = 1
page_size: int = 20
# #endregion ValidationTaskListResponse
# #region ValidationRunResponse [C:1] [TYPE Class]
# @BRIEF Schema for validation run history listing.
class ValidationRunResponse(BaseModel):
id: str
task_id: str | None = None
dashboard_id: str
environment_id: str | None = None
status: str
summary: str = ""
timestamp: datetime | None = None
screenshot_path: str | None = None
task_name: str | None = None # resolved from ValidationPolicy at query time
class Config:
from_attributes = True
# #endregion ValidationRunResponse
# #region ValidationRunListResponse [C:1] [TYPE Class]
# @BRIEF Schema for paginated run list response.
class ValidationRunListResponse(BaseModel):
items: list[ValidationRunResponse] = []
total: int = 0
page: int = 1
page_size: int = 20
# #endregion ValidationRunListResponse
# #region ValidationRunDetailResponse [C:1] [TYPE Class]
# @BRIEF Schema for full run detail including parsed issues and raw response.
class ValidationRunDetailResponse(BaseModel):
id: str
task_id: str | None = None
dashboard_id: str
environment_id: str | None = None
status: str
summary: str = ""
timestamp: datetime | None = None
screenshot_path: str | None = None
issues: list[dict[str, Any]] = Field(default_factory=list)
raw_response: str | None = None
task_name: str | None = None
class Config:
from_attributes = True
# #endregion ValidationRunDetailResponse
# #region TriggerRunResponse [C:1] [TYPE Class]
# @BRIEF Schema for trigger-run response — returns spawned task ID.
class TriggerRunResponse(BaseModel):
task_id: str
spawned_task_id: str
status: str = "PENDING"
message: str = "Validation task spawned"
# #endregion TriggerRunResponse
# #endregion ValidationSchemas

View File

@@ -0,0 +1,135 @@
# #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:
query = query.filter(ValidationRecord.task_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),
)
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

@@ -0,0 +1,265 @@
# #region ValidationService [C:3] [TYPE Module] [SEMANTICS validation, service, sqlalchemy, task]
# @BRIEF Business logic for validation task CRUD and trigger-run.
# @LAYER Service
from datetime import time
from typing import Any
from sqlalchemy.orm import Session
from ..core.logger import logger
from ..core.task_manager import TaskManager
from ..models.llm import LLMProvider, ValidationPolicy, ValidationRecord
from ..schemas.validation import ValidationTaskCreate, ValidationTaskResponse, ValidationTaskUpdate
from .llm_provider import LLMProviderService
def _parse_time(value: str | None) -> time | None:
"""Parse HH:MM string to datetime.time, or return None."""
if not value:
return None
try:
parts = value.strip().split(":")
return time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
def _format_time(value: time | None) -> str | None:
"""Format datetime.time to HH:MM string, or return None."""
if value is None:
return None
return f"{value.hour:02d}:{value.minute:02d}"
# #region ValidationTaskService [C:4] [TYPE Class]
# @BRIEF Validation task (policy) CRUD with provider validation and trigger-run.
# @RATIONALE Service-layer class for validation task CRUD with provider validation and trigger-run. Separates API layer concerns from business logic; enables unit testing without HTTP.
# @REJECTED Embedding CRUD in route handlers rejected — would duplicate validation logic across endpoints; centralized service ensures consistent provider/environment validation before persistence.
class ValidationTaskService:
def __init__(self, db: Session, config_manager=None, username: str | None = None):
self.db = db
self.config_manager = config_manager
self.username = username or "system"
# #region _validate_provider [C:2] [TYPE Function]
# @RATIONALE Centralizes multimodal LLM provider check so all task operations (create, update, trigger) share the same validation gate. Uses LLMProviderService for reuse.
# @REJECTED Duplicating multimodal check in each caller rejected — would create maintenance burden if provider validation rules change or error messages need updating.
def _validate_provider(self, provider_id: str) -> LLMProvider:
if not provider_id or not provider_id.strip():
raise ValueError("provider_id is required")
svc = LLMProviderService(self.db)
provider = svc.get_provider(provider_id)
if not provider:
raise ValueError(f"LLM provider '{provider_id}' not found")
if not bool(provider.is_multimodal):
raise ValueError(
f"LLM provider '{provider.name}' is not multimodal. "
"Dashboard validation requires a multimodal model (image input support)."
)
return provider
# #endregion _validate_provider
# #region _validate_environment [C:1] [TYPE Function]
def _validate_environment(self, environment_id: str) -> None:
if not self.config_manager:
return
env = self.config_manager.get_environment(environment_id)
if not env:
raise ValueError(f"Environment '{environment_id}' not found")
# #endregion _validate_environment
# #region _policy_to_response [C:2] [TYPE Function]
def _policy_to_response(self, policy: ValidationPolicy) -> ValidationTaskResponse:
last_run = None
if policy.dashboard_ids:
last_run = (
self.db.query(ValidationRecord)
.filter(
ValidationRecord.dashboard_id.in_(policy.dashboard_ids),
ValidationRecord.environment_id == policy.environment_id,
)
.order_by(ValidationRecord.timestamp.desc())
.first()
)
return ValidationTaskResponse(
id=policy.id,
name=policy.name,
environment_id=policy.environment_id,
is_active=bool(policy.is_active) if policy.is_active is not None else True,
dashboard_ids=list(policy.dashboard_ids) if policy.dashboard_ids else [],
provider_id=policy.provider_id,
schedule_days=list(policy.schedule_days) if policy.schedule_days else None,
window_start=_format_time(policy.window_start),
window_end=_format_time(policy.window_end),
notify_owners=bool(policy.notify_owners) if policy.notify_owners is not None else True,
custom_channels=list(policy.custom_channels) if policy.custom_channels else None,
alert_condition=policy.alert_condition or "FAIL_ONLY",
created_at=policy.created_at,
updated_at=policy.updated_at,
last_run_status=str(last_run.status) if last_run else None,
last_run_at=last_run.timestamp if last_run else None,
)
# #endregion _policy_to_response
# #region list_tasks [C:3] [TYPE Function]
# @RATIONALE Uses query-time filtering with optional is_active/environment_id params rather than post-filtering results in memory, reducing overhead with large task counts.
# @REJECTED In-memory filtering after loading all rows rejected — would be inefficient with hundreds of tasks; database-level filtering scales better.
def list_tasks(
self,
is_active: bool | None = None,
environment_id: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[int, list[ValidationTaskResponse]]:
query = self.db.query(ValidationPolicy)
if is_active is not None:
query = query.filter(ValidationPolicy.is_active == is_active)
if environment_id:
query = query.filter(ValidationPolicy.environment_id == environment_id)
total = query.count()
policies = (
query.order_by(ValidationPolicy.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return total, [self._policy_to_response(p) for p in policies]
# #endregion list_tasks
# #region create_task [C:4] [TYPE Function]
# @RATIONALE Validates provider (multimodal check) and environment before persisting the policy, ensuring referential integrity at creation time and preventing silent runtime failures.
# @REJECTED Lazy validation on first run rejected — would allow creation of invalid task configurations with non-multimodal providers that fail only at runtime during dashboard validation.
def create_task(self, payload: ValidationTaskCreate) -> ValidationTaskResponse:
self._validate_provider(payload.provider_id)
self._validate_environment(payload.environment_id)
ws = _parse_time(payload.window_start)
we = _parse_time(payload.window_end)
policy = ValidationPolicy(
name=payload.name,
environment_id=payload.environment_id,
is_active=payload.is_active,
dashboard_ids=payload.dashboard_ids or [],
provider_id=payload.provider_id,
schedule_days=payload.schedule_days or [],
window_start=ws or time(9, 0),
window_end=we or time(18, 0),
notify_owners=payload.notify_owners,
custom_channels=payload.custom_channels,
alert_condition=payload.alert_condition or "FAIL_ONLY",
)
self.db.add(policy)
self.db.commit()
self.db.refresh(policy)
logger.info(
"[ValidationTaskService] Created task '%s' (id=%s)", policy.name, policy.id
)
return self._policy_to_response(policy)
# #endregion create_task
# #region get_task [C:3] [TYPE Function]
# @RATIONALE Returns full task response including last_run_status via joined subquery — gives the UI immediate visibility into task health without a second API call.
# @REJECTED Separate query for last run status rejected — double-query pattern would add latency on every task detail request.
def get_task(self, task_id: str) -> ValidationTaskResponse:
policy = self.db.query(ValidationPolicy).filter(ValidationPolicy.id == task_id).first()
if not policy:
raise ValueError(f"Validation task '{task_id}' not found")
return self._policy_to_response(policy)
# #endregion get_task
# #region update_task [C:4] [TYPE Function]
# @RATIONALE Re-validates provider/environment only when those fields change (via exclude_unset), avoiding unnecessary DB calls on partial updates and allowing other fields to be updated independently.
# @REJECTED Full re-validation on every update rejected — would prevent updating non-critical fields (e.g., schedule, name) when a provider is temporarily unavailable.
def update_task(self, task_id: str, payload: ValidationTaskUpdate) -> ValidationTaskResponse:
policy = self.db.query(ValidationPolicy).filter(ValidationPolicy.id == task_id).first()
if not policy:
raise ValueError(f"Validation task '{task_id}' not found")
update_data = payload.model_dump(exclude_unset=True)
if "provider_id" in update_data and update_data["provider_id"]:
self._validate_provider(update_data["provider_id"])
if "environment_id" in update_data and update_data["environment_id"]:
self._validate_environment(update_data["environment_id"])
if "window_start" in update_data:
ws = _parse_time(update_data.pop("window_start", None))
if ws is not None:
policy.window_start = ws
if "window_end" in update_data:
we = _parse_time(update_data.pop("window_end", None))
if we is not None:
policy.window_end = we
for key, value in update_data.items():
if hasattr(policy, key):
setattr(policy, key, value)
self.db.commit()
self.db.refresh(policy)
logger.info("[ValidationTaskService] Updated task '%s' (id=%s)", policy.name, policy.id)
return self._policy_to_response(policy)
# #endregion update_task
# #region delete_task [C:3] [TYPE Function]
# @RATIONALE Scope run deletion by policy_id instead of dashboard_id+environment_id to prevent deleting runs belonging to other tasks that validate the same dashboard. Requires policy_id column on ValidationRecord, set by the plugin during execution.
# @REJECTED Always-cascade delete rejected would destroy run history unexpectedly. Scoping by dashboard_id+environment_id rejected caused cross-task data loss (F4). Explicit delete_runs flag with policy_id scoping gives users safe control.
def delete_task(self, task_id: str, delete_runs: bool = False) -> None:
policy = self.db.query(ValidationPolicy).filter(ValidationPolicy.id == task_id).first()
if not policy:
raise ValueError(f"Validation task '{task_id}' not found")
if delete_runs:
self.db.query(ValidationRecord).filter(
ValidationRecord.policy_id == policy.id,
).delete(synchronize_session=False)
self.db.delete(policy)
self.db.commit()
logger.info(
"[ValidationTaskService] Deleted task '%s' (id=%s)", policy.name, policy.id
)
# #endregion delete_task
# #region trigger_run [C:4] [TYPE Function]
# @RATIONALE Uses TaskManager.create_task() to spawn validation via the llm_dashboard_validation plugin — reuses existing scheduling, persistence, retry, and notification infrastructure.
# @REJECTED Direct LLM provider call from service layer rejected — would bypass TaskManager's persistence, retry logic, logging pipeline, and WebSocket notifications.
async def trigger_run(self, task_id: str, task_manager: TaskManager) -> str:
policy = self.db.query(ValidationPolicy).filter(ValidationPolicy.id == task_id).first()
if not policy:
raise ValueError(f"Validation task '{task_id}' not found")
if not policy.dashboard_ids:
raise ValueError(f"Validation task '{task_id}' has no dashboard IDs configured")
if policy.provider_id:
self._validate_provider(policy.provider_id)
dashboard_id = policy.dashboard_ids[0]
params: dict[str, Any] = {
"dashboard_id": dashboard_id,
"environment_id": policy.environment_id,
"provider_id": policy.provider_id or "",
"policy_id": task_id,
}
task = await task_manager.create_task(plugin_id="llm_dashboard_validation", params=params)
logger.info(
"[ValidationTaskService] Triggered run for task '%s' dashboard=%s spawned=%s",
policy.name, dashboard_id, task.id,
)
return task.id
# #endregion trigger_run
# #endregion ValidationTaskService
# #endregion ValidationService

View File

@@ -0,0 +1,102 @@
// #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

@@ -112,6 +112,19 @@ export function buildSidebarSections(i18nState, user, features = {}) {
{ label: nav.translation_history, path: "/translate/history", requiredPermission: "translate.job", requiredAction: "VIEW" },
],
},
{
id: "validation",
label: nav.validation,
icon: "activity",
tone: "from-amber-100 to-yellow-100 text-amber-700 ring-amber-200",
path: "/validation",
requiredPermission: "validation.task",
requiredAction: "VIEW",
subItems: [
{ label: nav.validation_tasks, path: "/validation", requiredPermission: "validation.task", requiredAction: "VIEW" },
{ label: nav.validation_history, path: "/validation/history", requiredPermission: "validation.run", requiredAction: "VIEW" },
],
},
{
id: "reports",
label: nav.reports,

View File

@@ -33,6 +33,7 @@ import enTasks from './locales/en/tasks.json';
import enDebug from './locales/en/debug.json';
import enAdmin from './locales/en/admin.json';
import enTranslate from './locales/en/translate.json';
import enValidation from './locales/en/validation.json';
import enAssistant from './locales/en/assistant.json';
import enAuth from './locales/en/auth.json';
import enMapper from './locales/en/mapper.json';
@@ -54,6 +55,7 @@ import ruTasks from './locales/ru/tasks.json';
import ruDebug from './locales/ru/debug.json';
import ruAdmin from './locales/ru/admin.json';
import ruTranslate from './locales/ru/translate.json';
import ruValidation from './locales/ru/validation.json';
import ruAssistant from './locales/ru/assistant.json';
import ruAuth from './locales/ru/auth.json';
import ruMapper from './locales/ru/mapper.json';
@@ -77,6 +79,7 @@ const en = {
debug: enDebug,
admin: enAdmin,
translate: enTranslate,
validation: enValidation,
assistant: enAssistant,
auth: enAuth,
mapper: enMapper,
@@ -100,6 +103,7 @@ const ru = {
debug: ruDebug,
admin: ruAdmin,
translate: ruTranslate,
validation: ruValidation,
assistant: ruAssistant,
auth: ruAuth,
mapper: ruMapper,

View File

@@ -27,6 +27,10 @@
"translation_dictionaries": "Dictionaries",
"translation_history": "History",
"validation": "Validation",
"validation_tasks": "Tasks",
"validation_history": "History",
"reports": "Reports",
"all_reports": "All Reports",

View File

@@ -0,0 +1,100 @@
{
"tasks_title": "Validation Tasks",
"new_task": "New Task",
"edit_task": "Edit Task",
"task_name": "Name",
"dashboard": "Dashboard",
"environment": "Environment",
"llm_provider": "LLM Provider",
"schedule": "Schedule",
"manual_only": "Manual only",
"scheduled": "Scheduled",
"active": "Active",
"inactive": "Inactive",
"run_now": "Run Now",
"running": "Running...",
"no_tasks": "No validation tasks yet. Create your first task to start monitoring dashboards.",
"no_runs": "No validation runs yet. Create a task and run it.",
"no_runs_filtered": "No runs match your filters.",
"delete_task_confirm": "Delete validation task \"{name}\"?",
"delete_task_with_runs": "Also delete all run history",
"delete_run_confirm": "Delete this validation run?",
"delete_success": "Deleted successfully.",
"delete_failed": "Failed to delete: {error}",
"save_success": "Task saved successfully.",
"save_failed": "Failed to save: {error}",
"run_triggered": "Validation run triggered.",
"run_failed": "Failed to trigger run: {error}",
"history_title": "Validation History",
"total_runs": "Total runs",
"passed": "Passed",
"failed": "Failed",
"warn_rate": "Warn rate",
"filter_task": "All Tasks",
"filter_status": "All Statuses",
"filter_dashboard": "Dashboard",
"filter_environment": "All Environments",
"date_from": "From",
"date_to": "To",
"apply": "Apply",
"clear": "Clear",
"last_run": "Last Run",
"issues": "Issues",
"view_report": "View Report",
"config_tab": "Configuration",
"history_tab": "Run History",
"provider_multimodal_required": "Dashboard validation requires a multimodal LLM provider (supports images).",
"form_validation_required": "This field is required",
"duplicate_task": "Duplicate",
"task_duplicated": "Task duplicated.",
"status_pass": "Pass",
"status_warn": "Warn",
"status_fail": "Fail",
"status_unknown": "Unknown",
"status_running": "Running",
"days_mon": "Mon",
"days_tue": "Tue",
"days_wed": "Wed",
"days_thu": "Thu",
"days_fri": "Fri",
"days_sat": "Sat",
"days_sun": "Sun",
"schedule_days": "Days",
"schedule_start": "Window Start",
"schedule_end": "Window End",
"schedule_expand": "Configure schedule",
"schedule_collapse": "Hide schedule",
"all_statuses": "All Statuses",
"all_tasks": "All Tasks",
"all_environments": "All Environments",
"run_history_empty": "No runs for this task yet.",
"created_at": "Created",
"updated_at": "Updated",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"back": "Back",
"confirm_delete": "Delete",
"cancel_delete": "Cancel",
"retry": "Retry",
"go_back": "Go back",
"task_not_found": "Task not found",
"task_not_found_desc": "The task you are looking for does not exist or has been deleted.",
"load_failed": "Failed to load tasks.",
"load_task_failed": "Failed to load task details.",
"load_runs_failed": "Failed to load run history.",
"create_task_cta": "Create Task",
"clear_filters": "Clear filters",
"no_tasks_filtered": "No tasks match your filters.",
"summary": "Summary",
"timestamp": "Timestamp",
"status": "Status",
"actions": "Actions",
"screenshot": "Screenshot",
"showing": "Showing {count} of {total}",
"previous": "Previous",
"next": "Next",
"last_run_status": "Last run",
"schedule_indicator": "Schedule",
"provider": "Provider"
}

View File

@@ -27,6 +27,10 @@
"translation_dictionaries": "Словари",
"translation_history": "История",
"validation": "Валидация",
"validation_tasks": "Задачи",
"validation_history": "История",
"reports": "Отчеты",
"all_reports": "Все отчеты",

View File

@@ -0,0 +1,100 @@
{
"tasks_title": "Задачи валидации",
"new_task": "Новая задача",
"edit_task": "Редактировать задачу",
"task_name": "Название",
"dashboard": "Дашборд",
"environment": "Окружение",
"llm_provider": "LLM провайдер",
"schedule": "Расписание",
"manual_only": "Только вручную",
"scheduled": "По расписанию",
"active": "Активна",
"inactive": "Неактивна",
"run_now": "Запустить",
"running": "Выполняется...",
"no_tasks": "Еще нет задач валидации. Создайте первую задачу для мониторинга дашбордов.",
"no_runs": "Еще нет запусков валидации. Создайте задачу и запустите её.",
"no_runs_filtered": "Нет запусков, соответствующих фильтрам.",
"delete_task_confirm": "Удалить задачу валидации \"{name}\"?",
"delete_task_with_runs": "Также удалить всю историю запусков",
"delete_run_confirm": "Удалить этот запуск валидации?",
"delete_success": "Успешно удалено.",
"delete_failed": "Не удалось удалить: {error}",
"save_success": "Задача успешно сохранена.",
"save_failed": "Не удалось сохранить: {error}",
"run_triggered": "Запуск валидации инициирован.",
"run_failed": "Не удалось запустить: {error}",
"history_title": "История валидации",
"total_runs": "Всего запусков",
"passed": "Успешно",
"failed": "Ошибок",
"warn_rate": "Предупреждений",
"filter_task": "Все задачи",
"filter_status": "Все статусы",
"filter_dashboard": "Дашборд",
"filter_environment": "Все окружения",
"date_from": "От",
"date_to": "До",
"apply": "Применить",
"clear": "Сбросить",
"last_run": "Последний запуск",
"issues": "Проблемы",
"view_report": "Отчет",
"config_tab": "Конфигурация",
"history_tab": "История запусков",
"provider_multimodal_required": "Для валидации дашбордов требуется мультимодальный LLM провайдер (поддерживающий изображения).",
"form_validation_required": "Это поле обязательно",
"duplicate_task": "Дублировать",
"task_duplicated": "Задача дублирована.",
"status_pass": "Успех",
"status_warn": "Предупреждение",
"status_fail": "Ошибка",
"status_unknown": "Неизвестно",
"status_running": "Выполняется",
"days_mon": "Пн",
"days_tue": "Вт",
"days_wed": "Ср",
"days_thu": "Чт",
"days_fri": "Пт",
"days_sat": "Сб",
"days_sun": "Вс",
"schedule_days": "Дни",
"schedule_start": "Начало окна",
"schedule_end": "Конец окна",
"schedule_expand": "Настроить расписание",
"schedule_collapse": "Скрыть расписание",
"all_statuses": "Все статусы",
"all_tasks": "Все задачи",
"all_environments": "Все окружения",
"run_history_empty": "Для этой задачи еще нет запусков.",
"created_at": "Создана",
"updated_at": "Обновлена",
"save": "Сохранить",
"saving": "Сохранение...",
"cancel": "Отмена",
"back": "Назад",
"confirm_delete": "Удалить",
"cancel_delete": "Отмена",
"retry": "Повторить",
"go_back": "Вернуться",
"task_not_found": "Задача не найдена",
"task_not_found_desc": "Задача, которую вы ищете, не существует или была удалена.",
"load_failed": "Не удалось загрузить задачи.",
"load_task_failed": "Не удалось загрузить данные задачи.",
"load_runs_failed": "Не удалось загрузить историю запусков.",
"create_task_cta": "Создать задачу",
"clear_filters": "Сбросить фильтры",
"no_tasks_filtered": "Нет задач, соответствующих фильтрам.",
"summary": "Результат",
"timestamp": "Время",
"status": "Статус",
"actions": "Действия",
"screenshot": "Скриншот",
"showing": "Показано {count} из {total}",
"previous": "Назад",
"next": "Вперед",
"last_run_status": "Последний запуск",
"schedule_indicator": "Расписание",
"provider": "Провайдер"
}

View File

@@ -0,0 +1,399 @@
<!-- #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

@@ -0,0 +1,699 @@
<!-- #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';
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 dashboardId = $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 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 is multimodal check ──
let selectedProvider = $derived(
llmProviders.find(p => String(p.id) === String(providerId))
);
let isProviderMultimodal = $derived(
selectedProvider?.supports_images || selectedProvider?.multimodal || 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 : [];
} catch {
environments = [];
}
try {
const consolidated = await api.getConsolidatedSettings?.() || {};
llmProviders = consolidated.llm_providers || [];
if (llmProviders.length === 0 && api.getLlmStatus) {
const llmStatus = await api.getLlmStatus().catch(() => ({}));
if (llmStatus?.providers) {
llmProviders = llmStatus.providers;
}
}
} catch {
llmProviders = [];
}
await searchDashboards('');
}
async function searchDashboards(search = '') {
dashboardsLoading = true;
try {
const result = await api.getDashboards?.(environmentId || '', { search, page_size: 50 }) || [];
dashboards = Array.isArray(result) ? result : (result?.results || result?.items || []);
} catch {
dashboards = [];
} finally {
dashboardsLoading = false;
}
}
async function loadTask() {
uxState = 'loading';
try {
task = await fetchTask(taskId);
if (!isMounted) return;
if (!task) {
uxState = 'not_found';
return;
}
name = task.name || '';
dashboardId = task.dashboard_ids?.[0] || '';
environmentId = task.environment_id || '';
providerId = task.provider_id || '';
isActive = task.is_active !== false;
scheduleDays = task.schedule_days || [];
scheduleStartTime = task.schedule_start_time || '';
scheduleEndTime = 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 (!dashboardId) 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: dashboardId ? [dashboardId] : [],
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'}: {new Date(task.created_at || task.created_on).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={() => { dashboardId = ''; 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 for="task-dashboard" class="block text-sm font-medium text-gray-700 mb-1">
{$t.validation?.dashboard || 'Dashboard'} <span class="text-red-500">*</span>
</label>
<select
id="task-dashboard"
bind:value={dashboardId}
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.dashboard_id ? 'border-red-300 bg-red-50' : 'border-gray-300'}"
disabled={!environmentId || dashboardsLoading}
>
<option value="">{$t.validation?.dashboard || 'Select dashboard...'}</option>
{#each dashboards as d (d.id)}
<option value={d.id}>{d.dashboard_title || d.name || d.id}</option>
{/each}
</select>
{#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 llmProviders as p (p.id)}
<option value={p.id}>
{p.name || p.provider_type || p.id}
{#if p.supports_images || p.multimodal}
(multimodal)
{/if}
</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>
</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>
{#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">{$t.validation?.run_history_empty || 'No runs for this task yet.'}</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.created_at || run.completed_at ? new Date(run.created_at || run.completed_at).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

@@ -0,0 +1,405 @@
<!-- #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 Array.isArray(run.issues) && run.issues.length > 0}
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
{$t.validation?.issues || 'Issues'}: {run.issues.length}
</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.created_at ? new Date(run.created_at).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 -->