From 36173c088092c7302b61269a301408fe0a53ce58 Mon Sep 17 00:00:00 2001 From: busya Date: Thu, 26 Feb 2026 19:40:00 +0300 Subject: [PATCH] test contracts --- .agent/workflows/speckit.fix.md | 2 +- .codex/prompts/speckit.fix.md | 2 +- backend/src/api/routes/reports.py | 14 + backend/src/app.py | 15 + backend/src/core/mapping_service.py | 15 + backend/src/core/task_manager/context.py | 14 + backend/src/core/task_manager/manager.py | 23 +- backend/src/core/task_manager/models.py | 8 + backend/src/core/task_manager/persistence.py | 27 + backend/src/core/task_manager/task_logger.py | 15 +- backend/src/models/report.py | 80 +++ backend/src/models/task.py | 55 ++ backend/src/services/llm_provider.py | 12 + backend/src/services/reports/normalizer.py | 14 + .../src/services/reports/report_service.py | 13 + backend/src/services/reports/type_profiles.py | 20 + frontend/src/components/TaskLogViewer.svelte | 25 +- frontend/src/lib/api/reports.js | 14 + .../assistant/AssistantChatPanel.svelte | 36 +- .../src/lib/components/layout/Sidebar.svelte | 64 ++- .../lib/components/layout/TaskDrawer.svelte | 140 +++-- .../lib/components/layout/TopNavbar.svelte | 90 ++- .../lib/components/reports/ReportCard.svelte | 15 + .../reports/ReportDetailPanel.svelte | 65 ++- .../lib/components/reports/ReportsList.svelte | 22 +- .../components/reports/reportTypeProfiles.js | 12 + frontend/src/lib/stores/taskDrawer.js | 13 + frontend/src/routes/+page.svelte | 11 + frontend/src/routes/dashboards/+page.svelte | 280 ++++++---- .../src/routes/dashboards/[id]/+page.svelte | 337 +++++++++--- frontend/src/routes/datasets/+page.svelte | 520 ++++++++++++------ .../src/routes/datasets/[id]/+page.svelte | 80 +-- frontend/src/routes/reports/+page.svelte | 115 ++-- .../routes/reports/llm/[taskId]/+page.svelte | 154 ++++-- frontend/src/routes/settings/+page.svelte | 248 ++++----- 35 files changed, 1811 insertions(+), 759 deletions(-) diff --git a/.agent/workflows/speckit.fix.md b/.agent/workflows/speckit.fix.md index b6ab50d..0218827 100644 --- a/.agent/workflows/speckit.fix.md +++ b/.agent/workflows/speckit.fix.md @@ -20,7 +20,7 @@ Analyze test failure reports, identify root causes, and fix implementation issue 1. **USE CODER MODE**: Always switch to `coder` mode for code fixes 2. **SEMANTIC PROTOCOL**: Never remove semantic annotations ([DEF], @TAGS). Only update code logic. -3. **TEST DATA**: If tests use @TEST_DATA fixtures, preserve them when fixing +3. **TEST DATA**: If tests use @TEST_ fixtures, preserve them when fixing 4. **NO DELETION**: Never delete existing tests or semantic annotations 5. **REPORT FIRST**: Always write a fix report before making changes diff --git a/.codex/prompts/speckit.fix.md b/.codex/prompts/speckit.fix.md index b6ab50d..0218827 100644 --- a/.codex/prompts/speckit.fix.md +++ b/.codex/prompts/speckit.fix.md @@ -20,7 +20,7 @@ Analyze test failure reports, identify root causes, and fix implementation issue 1. **USE CODER MODE**: Always switch to `coder` mode for code fixes 2. **SEMANTIC PROTOCOL**: Never remove semantic annotations ([DEF], @TAGS). Only update code logic. -3. **TEST DATA**: If tests use @TEST_DATA fixtures, preserve them when fixing +3. **TEST DATA**: If tests use @TEST_ fixtures, preserve them when fixing 4. **NO DELETION**: Never delete existing tests or semantic annotations 5. **REPORT FIRST**: Always write a fix report before making changes diff --git a/backend/src/api/routes/reports.py b/backend/src/api/routes/reports.py index b1bdf0d..1a023f7 100644 --- a/backend/src/api/routes/reports.py +++ b/backend/src/api/routes/reports.py @@ -62,6 +62,20 @@ def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List: # @PRE: authenticated/authorized request and validated query params. # @POST: returns {items,total,page,page_size,has_next,applied_filters}. # @POST: deterministic error payload for invalid filters. +# +# @TEST_CONTRACT: ListReportsApi -> +# { +# required_fields: {page: int, page_size: int, sort_by: str, sort_order: str}, +# optional_fields: {task_types: str, statuses: str, search: str}, +# invariants: [ +# "Returns ReportCollection on success", +# "Raises HTTPException 400 for invalid query parameters" +# ] +# } +# @TEST_FIXTURE: valid_list_request -> {"page": 1, "page_size": 20} +# @TEST_EDGE: invalid_task_type_filter -> raises HTTPException(400) +# @TEST_EDGE: malformed_query -> raises HTTPException(400) +# @TEST_INVARIANT: consistent_list_payload -> verifies: [valid_list_request] @router.get("", response_model=ReportCollection) async def list_reports( page: int = Query(1, ge=1), diff --git a/backend/src/app.py b/backend/src/app.py index 9530d90..36989fc 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -147,6 +147,21 @@ app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"] # @POST: WebSocket connection is managed and logs are streamed until disconnect. # @TIER: CRITICAL # @UX_STATE: Connecting -> Streaming -> (Disconnected) +# +# @TEST_CONTRACT: WebSocketLogStreamApi -> +# { +# required_fields: {websocket: WebSocket, task_id: str}, +# optional_fields: {source: str, level: str}, +# invariants: [ +# "Accepts the WebSocket connection", +# "Applies source and level filters correctly to streamed logs", +# "Cleans up subscriptions on disconnect" +# ] +# } +# @TEST_FIXTURE: valid_ws_connection -> {"task_id": "test_1", "source": "plugin"} +# @TEST_EDGE: task_not_found_ws -> closes connection or sends error +# @TEST_EDGE: empty_task_logs -> waits for new logs +# @TEST_INVARIANT: consistent_streaming -> verifies: [valid_ws_connection] @app.websocket("/ws/logs/{task_id}") async def websocket_endpoint( websocket: WebSocket, diff --git a/backend/src/core/mapping_service.py b/backend/src/core/mapping_service.py index e7a5258..127e6b1 100644 --- a/backend/src/core/mapping_service.py +++ b/backend/src/core/mapping_service.py @@ -23,6 +23,21 @@ from src.core.logger import logger, belief_scope # [DEF:IdMappingService:Class] # @TIER: CRITICAL # @PURPOSE: Service handling the cataloging and retrieval of remote Superset Integer IDs. +# +# @TEST_CONTRACT: IdMappingServiceModel -> +# { +# required_fields: {db_session: Session}, +# invariants: [ +# "sync_environment correctly creates or updates ResourceMapping records", +# "get_remote_id returns an integer or None", +# "get_remote_ids_batch returns a dictionary of valid UUIDs to integers" +# ] +# } +# @TEST_FIXTURE: valid_mapping_service -> {"db_session": "MockSession()"} +# @TEST_EDGE: sync_api_failure -> handles exception gracefully +# @TEST_EDGE: get_remote_id_not_found -> returns None +# @TEST_EDGE: get_batch_empty_list -> returns empty dict +# @TEST_INVARIANT: resilient_fetching -> verifies: [sync_api_failure] class IdMappingService: # [DEF:__init__:Function] diff --git a/backend/src/core/task_manager/context.py b/backend/src/core/task_manager/context.py index b641d93..5092524 100644 --- a/backend/src/core/task_manager/context.py +++ b/backend/src/core/task_manager/context.py @@ -19,6 +19,20 @@ from ..logger import belief_scope # @TIER: CRITICAL # @INVARIANT: logger is always a valid TaskLogger instance. # @UX_STATE: Idle -> Active -> Complete +# +# @TEST_CONTRACT: TaskContextInit -> +# { +# required_fields: {task_id: str, add_log_fn: Callable, params: dict}, +# optional_fields: {default_source: str}, +# invariants: [ +# "task_id matches initialized logger's task_id", +# "logger is a valid TaskLogger instance" +# ] +# } +# @TEST_FIXTURE: valid_context -> {"task_id": "123", "add_log_fn": lambda *args: None, "params": {"k": "v"}, "default_source": "plugin"} +# @TEST_EDGE: missing_task_id -> raises TypeError +# @TEST_EDGE: missing_add_log_fn -> raises TypeError +# @TEST_INVARIANT: logger_initialized -> verifies: [valid_context] class TaskContext: """ Execution context provided to plugins during task execution. diff --git a/backend/src/core/task_manager/manager.py b/backend/src/core/task_manager/manager.py index b12297f..5c136fe 100644 --- a/backend/src/core/task_manager/manager.py +++ b/backend/src/core/task_manager/manager.py @@ -39,17 +39,20 @@ from ..logger import logger, belief_scope, should_log_task_level # @INVARIANT: Task IDs are unique within the registry. # @INVARIANT: Each task has exactly one status at any time. # @INVARIANT: Log entries are never deleted after being added to a task. -# @TEST_CONTRACT: TaskManager -> { -# required_fields: {plugin_id: str, params: dict}, -# optional_fields: {user_id: str}, -# invariants: ["Task IDs are unique within the registry", "Each task has exactly one status at any time"] +# +# @TEST_CONTRACT: TaskManagerModel -> +# { +# required_fields: {plugin_loader: PluginLoader}, +# invariants: [ +# "Tasks are persisted immediately upon creation", +# "Running tasks use a thread pool or asyncio event loop based on executor type", +# "Log flushing runs on a background thread" +# ] # } -# @TEST_FIXTURE: create_valid_task -> {"plugin_id": "migration_plugin", "params": {"source": "A", "target": "B"}} -# @TEST_EDGE: missing_required_field -> {"plugin_id": null} -# @TEST_EDGE: empty_response -> {"params": {}} -# @TEST_EDGE: invalid_type -> {"params": "string_instead_of_dict"} -# @TEST_EDGE: external_failure -> {"plugin_not_found": true} -# @TEST_INVARIANT: single_status -> verifies: [create_valid_task, external_failure] +# @TEST_FIXTURE: valid_manager -> {"plugin_loader": "MockPluginLoader()"} +# @TEST_EDGE: create_task_invalid_plugin -> raises ValueError +# @TEST_EDGE: create_task_invalid_params -> raises ValueError +# @TEST_INVARIANT: lifecycle_management -> verifies: [valid_manager] """ Manages the lifecycle of tasks, including their creation, execution, and state tracking. """ diff --git a/backend/src/core/task_manager/models.py b/backend/src/core/task_manager/models.py index c49b094..a5232e2 100644 --- a/backend/src/core/task_manager/models.py +++ b/backend/src/core/task_manager/models.py @@ -45,6 +45,14 @@ class LogLevel(str, Enum): # @PURPOSE: A Pydantic model representing a single, structured log entry associated with a task. # @TIER: CRITICAL # @INVARIANT: Each log entry has a unique timestamp and source. +# +# @TEST_CONTRACT: LogEntryModel -> +# { +# required_fields: {message: str}, +# optional_fields: {timestamp: datetime, level: str, source: str, context: dict, metadata: dict} +# } +# @TEST_FIXTURE: valid_log_entry -> {"message": "Plugin initialized"} +# @TEST_EDGE: empty_message -> {"message": ""} class LogEntry(BaseModel): timestamp: datetime = Field(default_factory=datetime.utcnow) level: str = Field(default="INFO") diff --git a/backend/src/core/task_manager/persistence.py b/backend/src/core/task_manager/persistence.py index b3b8746..b32d081 100644 --- a/backend/src/core/task_manager/persistence.py +++ b/backend/src/core/task_manager/persistence.py @@ -24,6 +24,20 @@ from ..logger import logger, belief_scope # @SEMANTICS: persistence, service, database, sqlalchemy # @PURPOSE: Provides methods to save and load tasks from the tasks.db database using SQLAlchemy. # @INVARIANT: Persistence must handle potentially missing task fields natively. +# +# @TEST_CONTRACT: TaskPersistenceService -> +# { +# required_fields: {}, +# invariants: [ +# "persist_task creates or updates a record", +# "load_tasks retrieves valid Task instances", +# "delete_tasks correctly removes records from the database" +# ] +# } +# @TEST_FIXTURE: valid_task_persistence -> {"task_id": "123", "status": "PENDING"} +# @TEST_EDGE: persist_invalid_task_type -> raises Exception +# @TEST_EDGE: load_corrupt_json_params -> handled gracefully +# @TEST_INVARIANT: accurate_round_trip -> verifies: [valid_task_persistence, load_corrupt_json_params] class TaskPersistenceService: # [DEF:_json_load_if_needed:Function] # @PURPOSE: Safely load JSON strings from DB if necessary @@ -245,6 +259,19 @@ class TaskPersistenceService: # @TIER: CRITICAL # @RELATION: DEPENDS_ON -> TaskLogRecord # @INVARIANT: Log entries are batch-inserted for performance. +# +# @TEST_CONTRACT: TaskLogPersistenceService -> +# { +# required_fields: {}, +# invariants: [ +# "add_logs efficiently saves logs to the database", +# "get_logs retrieves properly filtered LogEntry objects" +# ] +# } +# @TEST_FIXTURE: valid_log_batch -> {"task_id": "123", "logs": [{"level": "INFO", "message": "msg"}]} +# @TEST_EDGE: empty_log_list -> no-op behavior +# @TEST_EDGE: add_logs_db_error -> rollback and log error +# @TEST_INVARIANT: accurate_log_aggregation -> verifies: [valid_log_batch] class TaskLogPersistenceService: """ Service for persisting and querying task logs. diff --git a/backend/src/core/task_manager/task_logger.py b/backend/src/core/task_manager/task_logger.py index 67d4bc1..a548ae1 100644 --- a/backend/src/core/task_manager/task_logger.py +++ b/backend/src/core/task_manager/task_logger.py @@ -15,8 +15,21 @@ from typing import Dict, Any, Optional, Callable # @PURPOSE: A wrapper around TaskManager._add_log that carries task_id and source context. # @TIER: CRITICAL # @INVARIANT: All log calls include the task_id and source. -# @TEST_DATA: task_logger -> {"task_id": "test_123", "source": "test_plugin"} # @UX_STATE: Idle -> Logging -> (system records log) +# +# @TEST_CONTRACT: TaskLoggerModel -> +# { +# required_fields: {task_id: str, add_log_fn: Callable}, +# optional_fields: {source: str}, +# invariants: [ +# "All specific log methods (info, error) delegate to _log", +# "with_source creates a new logger with the same task_id" +# ] +# } +# @TEST_FIXTURE: valid_task_logger -> {"task_id": "test_123", "add_log_fn": lambda *args: None, "source": "test_plugin"} +# @TEST_EDGE: missing_task_id -> raises TypeError +# @TEST_EDGE: invalid_add_log_fn -> raises TypeError +# @TEST_INVARIANT: consistent_delegation -> verifies: [valid_task_logger] class TaskLogger: """ A dedicated logger for tasks that automatically tags logs with source attribution. diff --git a/backend/src/models/report.py b/backend/src/models/report.py index a1abb49..abb3755 100644 --- a/backend/src/models/report.py +++ b/backend/src/models/report.py @@ -47,6 +47,19 @@ class ReportStatus(str, Enum): # @INVARIANT: The properties accurately describe error state. # @SEMANTICS: error, context, payload # @PURPOSE: Error and recovery context for failed/partial reports. +# +# @TEST_CONTRACT: ErrorContextModel -> +# { +# required_fields: { +# message: str +# }, +# optional_fields: { +# code: str, +# next_actions: list[str] +# } +# } +# @TEST_FIXTURE: basic_error -> {"message": "Connection timeout", "code": "ERR_504", "next_actions": ["retry"]} +# @TEST_EDGE: missing_message -> {"code": "ERR_504"} class ErrorContext(BaseModel): code: Optional[str] = None message: str @@ -59,6 +72,36 @@ class ErrorContext(BaseModel): # @INVARIANT: Must represent canonical task record attributes. # @SEMANTICS: report, model, summary # @PURPOSE: Canonical normalized report envelope for one task execution. +# +# @TEST_CONTRACT: TaskReportModel -> +# { +# required_fields: { +# report_id: str, +# task_id: str, +# task_type: TaskType, +# status: ReportStatus, +# updated_at: datetime, +# summary: str +# }, +# invariants: [ +# "report_id is a non-empty string", +# "task_id is a non-empty string", +# "summary is a non-empty string" +# ] +# } +# @TEST_FIXTURE: valid_task_report -> +# { +# report_id: "rep-123", +# task_id: "task-456", +# task_type: "migration", +# status: "success", +# updated_at: "2026-02-26T12:00:00Z", +# summary: "Migration completed successfully" +# } +# @TEST_EDGE: empty_report_id -> {"report_id": " ", "task_id": "task-456", "task_type": "migration", "status": "success", "updated_at": "2026-02-26T12:00:00Z", "summary": "Done"} +# @TEST_EDGE: empty_summary -> {"report_id": "rep-123", "task_id": "task-456", "task_type": "migration", "status": "success", "updated_at": "2026-02-26T12:00:00Z", "summary": ""} +# @TEST_EDGE: invalid_task_type -> {"report_id": "rep-123", "task_id": "task-456", "task_type": "invalid_type", "status": "success", "updated_at": "2026-02-26T12:00:00Z", "summary": "Done"} +# @TEST_INVARIANT: non_empty_validators -> verifies: [empty_report_id, empty_summary] class TaskReport(BaseModel): report_id: str task_id: str @@ -85,6 +128,25 @@ class TaskReport(BaseModel): # @INVARIANT: Time and pagination queries are mutually consistent. # @SEMANTICS: query, filter, search # @PURPOSE: Query object for server-side report filtering, sorting, and pagination. +# +# @TEST_CONTRACT: ReportQueryModel -> +# { +# optional_fields: { +# page: int, page_size: int, task_types: list[TaskType], statuses: list[ReportStatus], +# time_from: datetime, time_to: datetime, search: str, sort_by: str, sort_order: str +# }, +# invariants: [ +# "page >= 1", "1 <= page_size <= 100", +# "sort_by in {'updated_at', 'status', 'task_type'}", +# "sort_order in {'asc', 'desc'}", +# "time_from <= time_to if both exist" +# ] +# } +# @TEST_FIXTURE: valid_query -> {"page": 1, "page_size":20, "sort_by": "updated_at", "sort_order": "desc"} +# @TEST_EDGE: invalid_page_size_large -> {"page_size": 150} +# @TEST_EDGE: invalid_sort_by -> {"sort_by": "unknown_field"} +# @TEST_EDGE: invalid_time_range -> {"time_from": "2026-02-26T12:00:00Z", "time_to": "2026-02-25T12:00:00Z"} +# @TEST_INVARIANT: attribute_constraints_enforced -> verifies: [invalid_page_size_large, invalid_sort_by, invalid_time_range] class ReportQuery(BaseModel): page: int = Field(default=1, ge=1) page_size: int = Field(default=20, ge=1, le=100) @@ -124,6 +186,16 @@ class ReportQuery(BaseModel): # @INVARIANT: Represents paginated data correctly. # @SEMANTICS: collection, pagination # @PURPOSE: Paginated collection of normalized task reports. +# +# @TEST_CONTRACT: ReportCollectionModel -> +# { +# required_fields: { +# items: list[TaskReport], total: int, page: int, page_size: int, has_next: bool, applied_filters: ReportQuery +# }, +# invariants: ["total >= 0", "page >= 1", "page_size >= 1"] +# } +# @TEST_FIXTURE: empty_collection -> {"items": [], "total": 0, "page": 1, "page_size": 20, "has_next": False, "applied_filters": {}} +# @TEST_EDGE: negative_total -> {"items": [], "total": -5, "page": 1, "page_size": 20, "has_next": False, "applied_filters": {}} class ReportCollection(BaseModel): items: List[TaskReport] total: int = Field(ge=0) @@ -139,6 +211,14 @@ class ReportCollection(BaseModel): # @INVARIANT: Incorporates a report and logs correctly. # @SEMANTICS: view, detail, logs # @PURPOSE: Detailed report representation including diagnostics and recovery actions. +# +# @TEST_CONTRACT: ReportDetailViewModel -> +# { +# required_fields: {report: TaskReport}, +# optional_fields: {timeline: list[dict], diagnostics: dict, next_actions: list[str]} +# } +# @TEST_FIXTURE: valid_detail -> {"report": {"report_id": "rep-1", "task_id": "task-1", "task_type": "backup", "status": "success", "updated_at": "2026-02-26T12:00:00Z", "summary": "Done"}} +# @TEST_EDGE: missing_report -> {} class ReportDetailView(BaseModel): report: TaskReport timeline: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/backend/src/models/task.py b/backend/src/models/task.py index 0dd4b38..c3cd002 100644 --- a/backend/src/models/task.py +++ b/backend/src/models/task.py @@ -39,6 +39,61 @@ class TaskRecord(Base): # @TIER: CRITICAL # @RELATION: DEPENDS_ON -> TaskRecord # @INVARIANT: Each log entry belongs to exactly one task. +# +# @TEST_CONTRACT: TaskLogCreate -> +# { +# required_fields: { +# task_id: str, +# timestamp: datetime, +# level: str, +# source: str, +# message: str +# }, +# optional_fields: { +# metadata_json: str, +# id: int +# }, +# invariants: [ +# "task_id matches an existing TaskRecord.id" +# ] +# } +# +# @TEST_FIXTURE: basic_info_log -> +# { +# task_id: "00000000-0000-0000-0000-000000000000", +# timestamp: "2026-02-26T12:00:00Z", +# level: "INFO", +# source: "system", +# message: "Task initialization complete" +# } +# +# @TEST_EDGE: missing_required_field -> +# { +# timestamp: "2026-02-26T12:00:00Z", +# level: "ERROR", +# source: "system", +# message: "Missing task_id" +# } +# +# @TEST_EDGE: invalid_type -> +# { +# task_id: "00000000-0000-0000-0000-000000000000", +# timestamp: "2026-02-26T12:00:00Z", +# level: 500, +# source: "system", +# message: "Integer level" +# } +# +# @TEST_EDGE: empty_message -> +# { +# task_id: "00000000-0000-0000-0000-000000000000", +# timestamp: "2026-02-26T12:00:00Z", +# level: "DEBUG", +# source: "system", +# message: "" +# } +# +# @TEST_INVARIANT: exact_one_task_association -> verifies: [basic_info_log, missing_required_field] class TaskLogRecord(Base): __tablename__ = "task_logs" diff --git a/backend/src/services/llm_provider.py b/backend/src/services/llm_provider.py index 84cd1fd..f43d3c0 100644 --- a/backend/src/services/llm_provider.py +++ b/backend/src/services/llm_provider.py @@ -18,6 +18,18 @@ import os # @TIER: CRITICAL # @PURPOSE: Handles encryption and decryption of sensitive data like API keys. # @INVARIANT: Uses a secret key from environment or a default one (fallback only for dev). +# +# @TEST_CONTRACT: EncryptionManagerModel -> +# { +# required_fields: {}, +# invariants: [ +# "encrypted data can be decrypted back to the original string" +# ] +# } +# @TEST_FIXTURE: basic_encryption_cycle -> {"data": "my_secret_key"} +# @TEST_EDGE: decrypt_invalid_data -> raises Exception +# @TEST_EDGE: empty_string_encryption -> {"data": ""} +# @TEST_INVARIANT: symmetric_encryption -> verifies: [basic_encryption_cycle, empty_string_encryption] class EncryptionManager: # [DEF:EncryptionManager.__init__:Function] # @PURPOSE: Initialize the encryption manager with a Fernet key. diff --git a/backend/src/services/reports/normalizer.py b/backend/src/services/reports/normalizer.py index 25146a8..78281bf 100644 --- a/backend/src/services/reports/normalizer.py +++ b/backend/src/services/reports/normalizer.py @@ -113,6 +113,20 @@ def extract_error_context(task: Task, report_status: ReportStatus) -> Optional[E # @POST: Returns TaskReport with required fields and deterministic fallback behavior. # @PARAM: task (Task) - Source task. # @RETURN: TaskReport - Canonical normalized report. +# +# @TEST_CONTRACT: NormalizeTaskReport -> +# { +# required_fields: {task: Task}, +# invariants: [ +# "Returns a valid TaskReport object", +# "Maps TaskStatus to ReportStatus deterministically", +# "Extracts ErrorContext for FAILED/PARTIAL tasks" +# ] +# } +# @TEST_FIXTURE: valid_task -> {"task": "MockTask(id='1', plugin_id='superset-migration', status=TaskStatus.SUCCESS)"} +# @TEST_EDGE: task_with_error -> {"task": "MockTask(status=TaskStatus.FAILED, logs=[LogEntry(level='ERROR', message='Failed')])"} +# @TEST_EDGE: unknown_plugin_type -> {"task": "MockTask(plugin_id='unknown-plugin', status=TaskStatus.PENDING)"} +# @TEST_INVARIANT: deterministic_normalization -> verifies: [valid_task, task_with_error, unknown_plugin_type] def normalize_task_report(task: Task) -> TaskReport: with belief_scope("normalize_task_report"): task_type = resolve_task_type(task.plugin_id) diff --git a/backend/src/services/reports/report_service.py b/backend/src/services/reports/report_service.py index 2f10732..4f6f20a 100644 --- a/backend/src/services/reports/report_service.py +++ b/backend/src/services/reports/report_service.py @@ -26,6 +26,19 @@ from .normalizer import normalize_task_report # @PRE: TaskManager dependency is initialized. # @POST: Provides deterministic list/detail report responses. # @INVARIANT: Service methods are read-only over task history source. +# +# @TEST_CONTRACT: ReportsServiceModel -> +# { +# required_fields: {task_manager: TaskManager}, +# invariants: [ +# "list_reports returns a matching ReportCollection", +# "get_report_detail returns a valid ReportDetailView or None" +# ] +# } +# @TEST_FIXTURE: valid_service -> {"task_manager": "MockTaskManager()"} +# @TEST_EDGE: empty_task_list -> returns empty ReportCollection +# @TEST_EDGE: report_not_found -> get_report_detail returns None +# @TEST_INVARIANT: consistent_pagination -> verifies: [valid_service] class ReportsService: # [DEF:__init__:Function] # @TIER: CRITICAL diff --git a/backend/src/services/reports/type_profiles.py b/backend/src/services/reports/type_profiles.py index f2188fb..3db894d 100644 --- a/backend/src/services/reports/type_profiles.py +++ b/backend/src/services/reports/type_profiles.py @@ -71,6 +71,17 @@ TASK_TYPE_PROFILES: Dict[TaskType, Dict[str, Any]] = { # @POST: Always returns one of TaskType enum values. # @PARAM: plugin_id (Optional[str]) - Source plugin/task identifier from task record. # @RETURN: TaskType - Resolved canonical type or UNKNOWN fallback. +# +# @TEST_CONTRACT: ResolveTaskType -> +# { +# required_fields: {plugin_id: str}, +# invariants: ["returns TaskType.UNKNOWN for missing/unmapped plugin_id"] +# } +# @TEST_FIXTURE: valid_plugin -> {"plugin_id": "superset-migration"} +# @TEST_EDGE: empty_plugin -> {"plugin_id": ""} +# @TEST_EDGE: none_plugin -> {"plugin_id": None} +# @TEST_EDGE: unknown_plugin -> {"plugin_id": "invalid-plugin"} +# @TEST_INVARIANT: fallback_to_unknown -> verifies: [empty_plugin, none_plugin, unknown_plugin] def resolve_task_type(plugin_id: Optional[str]) -> TaskType: with belief_scope("resolve_task_type"): normalized = (plugin_id or "").strip() @@ -86,6 +97,15 @@ def resolve_task_type(plugin_id: Optional[str]) -> TaskType: # @POST: Returns a profile dict and never raises for unknown types. # @PARAM: task_type (TaskType) - Canonical task type. # @RETURN: Dict[str, Any] - Profile metadata used by normalization and UI contracts. +# +# @TEST_CONTRACT: GetTypeProfile -> +# { +# required_fields: {task_type: TaskType}, +# invariants: ["returns a valid metadata dictionary even for UNKNOWN"] +# } +# @TEST_FIXTURE: valid_profile -> {"task_type": "migration"} +# @TEST_EDGE: missing_profile -> {"task_type": "some_new_type"} +# @TEST_INVARIANT: always_returns_dict -> verifies: [valid_profile, missing_profile] def get_type_profile(task_type: TaskType) -> Dict[str, Any]: with belief_scope("get_type_profile"): return TASK_TYPE_PROFILES.get(task_type, TASK_TYPE_PROFILES[TaskType.UNKNOWN]) diff --git a/frontend/src/components/TaskLogViewer.svelte b/frontend/src/components/TaskLogViewer.svelte index d68303b..31d43f4 100644 --- a/frontend/src/components/TaskLogViewer.svelte +++ b/frontend/src/components/TaskLogViewer.svelte @@ -19,6 +19,21 @@ * @UX_STATE Error -> Shows error message with recovery option * @UX_FEEDBACK Auto-scroll keeps newest logs visible * @UX_RECOVERY Refresh button re-fetches logs from API + * + * @TEST_CONTRACT Component_TaskLogViewer -> + * { + * required_props: {taskId: string}, + * optional_props: {show: boolean, inline: boolean, taskStatus: string, realTimeLogs: array}, + * invariants: [ + * "Fetches initial logs on mount if taskId is provided", + * "Updates log list when realTimeLogs prop changes without duplicating entries", + * "Displays Loading, Error, or Data states correctly" + * ] + * } + * @TEST_FIXTURE valid_viewer -> {taskId: "123", show: true} + * @TEST_EDGE no_task_id -> does not fetch, shows empty/loading indefinitely if show=true + * @TEST_EDGE api_error -> transitions to Error state and displays retry button + * @TEST_INVARIANT displays_logs -> verifies: [valid_viewer] */ import { createEventDispatcher, onDestroy } from "svelte"; import { getTaskLogs } from "../services/taskService.js"; @@ -144,7 +159,7 @@
- {$t.tasks?.loading } + {$t.tasks?.loading} {:else if error}
{error} {$t.common?.retry}
{:else} @@ -203,7 +218,7 @@ class="text-lg font-medium text-gray-100" id="modal-title" > - {$t.tasks?.logs_title } + {$t.tasks?.logs_title}
{#if loading && logs.length === 0}

- {$t.tasks?.loading } + {$t.tasks?.loading}

{:else if error}

{error}

diff --git a/frontend/src/lib/api/reports.js b/frontend/src/lib/api/reports.js index 58e8af1..a0ab7a4 100644 --- a/frontend/src/lib/api/reports.js +++ b/frontend/src/lib/api/reports.js @@ -59,6 +59,20 @@ export function normalizeApiError(error) { // @PURPOSE: Fetch unified report list using existing request wrapper. // @PRE: valid auth context for protected endpoint. // @POST: Returns parsed payload or structured error for UI-state mapping. +// +// @TEST_CONTRACT: GetReportsApi -> +// { +// required_fields: {}, +// optional_fields: {options: Object}, +// invariants: [ +// "Fetches from /reports with built query string", +// "Returns response payload on success", +// "Catches and normalizes errors using normalizeApiError" +// ] +// } +// @TEST_FIXTURE: valid_get_reports -> {"options": {"page": 1}} +// @TEST_EDGE: api_fetch_failure -> api.fetchApi throws error +// @TEST_INVARIANT: error_normalization -> verifies: [api_fetch_failure] export async function getReports(options = {}) { try { console.log("[reports][api][getReports:STARTED]", options); diff --git a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte index ea7b1c8..35e4320 100644 --- a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte +++ b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte @@ -23,6 +23,21 @@ * @UX_TEST: NeedsConfirmation -> {click: confirm action, expected: started response with task_id} * @TEST_DATA: assistant_llm_ready -> {"llmStatus":{"configured":true,"reason":"ok"},"messages":[{"role":"assistant","text":"Ready","state":"success"}]} * @TEST_DATA: assistant_llm_not_configured -> {"llmStatus":{"configured":false,"reason":"invalid_api_key"}} + * + * @TEST_CONTRACT Component_AssistantChatPanel -> + * { + * required_props: {}, + * optional_props: {}, + * invariants: [ + * "Loads history and LLM status on mount/open", + * "Appends messages and sends to API correctly", + * "Handles action buttons (confirm, open task) properly" + * ] + * } + * @TEST_FIXTURE chat_open -> {"isOpen": true, "messages": [{"role": "assistant", "text": "Hello"}]} + * @TEST_EDGE server_error -> Appends error message with "failed" state + * @TEST_EDGE llm_not_ready -> Renders LLM config warning banner + * @TEST_INVARIANT action_handling -> verifies: [chat_open] */ import { onMount } from "svelte"; @@ -551,15 +566,22 @@
{#if !llmReady} -
-
{$t.dashboard?.llm_not_configured || "LLM is not configured"}
+
+
+ {$t.dashboard?.llm_not_configured || "LLM is not configured"} +
{#if llmStatusReason === "no_active_provider"} - {$t.dashboard?.llm_configure_provider || "No active LLM provider. Configure it in Admin -> LLM Settings."} + {$t.dashboard?.llm_configure_provider || + "No active LLM provider. Configure it in Admin -> LLM Settings."} {:else if llmStatusReason === "invalid_api_key"} - {$t.dashboard?.llm_configure_key || "Invalid LLM API key. Update and save a real key in Admin -> LLM Settings."} + {$t.dashboard?.llm_configure_key || + "Invalid LLM API key. Update and save a real key in Admin -> LLM Settings."} {:else} - {$t.dashboard?.llm_status_unavailable || "LLM status is unavailable. Check settings and backend logs."} + {$t.dashboard?.llm_status_unavailable || + "LLM status is unavailable. Check settings and backend logs."} {/if}
@@ -758,7 +780,9 @@ bind:value={input} rows="2" placeholder={$t.assistant?.input_placeholder} - class="min-h-[52px] w-full resize-y rounded-lg border px-3 py-2 text-sm outline-none transition {llmReady ? 'border-slate-300 focus:border-sky-400 focus:ring-2 focus:ring-sky-100' : 'border-rose-300 bg-rose-50 focus:border-rose-400 focus:ring-2 focus:ring-rose-100'}" + class="min-h-[52px] w-full resize-y rounded-lg border px-3 py-2 text-sm outline-none transition {llmReady + ? 'border-slate-300 focus:border-sky-400 focus:ring-2 focus:ring-sky-100' + : 'border-rose-300 bg-rose-50 focus:border-rose-400 focus:ring-2 focus:ring-rose-100'}" on:keydown={handleKeydown} >
{:else} @@ -293,10 +309,10 @@
{/if} diff --git a/frontend/src/lib/components/layout/TaskDrawer.svelte b/frontend/src/lib/components/layout/TaskDrawer.svelte index 0e6fa21..a87d896 100644 --- a/frontend/src/lib/components/layout/TaskDrawer.svelte +++ b/frontend/src/lib/components/layout/TaskDrawer.svelte @@ -18,6 +18,21 @@ * @UX_RECOVERY: Back button shows task list when viewing task details * @TEST_DATA: llm_task_success_with_fail_result -> {"activeTaskDetails":{"plugin_id":"llm_dashboard_validation","status":"SUCCESS","result":{"status":"FAIL"}}} * @TEST_DATA: llm_task_success_with_pass_result -> {"activeTaskDetails":{"plugin_id":"llm_dashboard_validation","status":"SUCCESS","result":{"status":"PASS"}}} + * + * @TEST_CONTRACT Component_TaskDrawer -> + * { + * required_props: {}, + * optional_props: {}, + * invariants: [ + * "Binds successfully to taskDrawerStore", + * "Renders list mode when no activeTaskId is set", + * "Renders detail mode and websocket stream when activeTaskId is set", + * "Closes properly on handleClose" + * ] + * } + * @TEST_FIXTURE init_state -> {} + * @TEST_EDGE empty_task_list -> displays \"No recent tasks\" + * @TEST_INVARIANT default_rendering -> verifies: [init_state] */ import { onDestroy } from "svelte"; @@ -112,9 +127,12 @@ } function llmValidationBadgeClass(tone) { - if (tone === "fail") return "text-rose-700 bg-rose-100 border border-rose-200"; - if (tone === "warn") return "text-amber-700 bg-amber-100 border border-amber-200"; - if (tone === "pass") return "text-emerald-700 bg-emerald-100 border border-emerald-200"; + if (tone === "fail") + return "text-rose-700 bg-rose-100 border border-rose-200"; + if (tone === "warn") + return "text-amber-700 bg-amber-100 border border-amber-200"; + if (tone === "pass") + return "text-emerald-700 bg-emerald-100 border border-emerald-200"; return "text-slate-700 bg-slate-100 border border-slate-200"; } @@ -169,7 +187,10 @@ function extractPrimaryDashboardId(task) { const result = task?.result || {}; const params = task?.params || {}; - if (Array.isArray(result?.migrated_dashboards) && result.migrated_dashboards[0]?.id) { + if ( + Array.isArray(result?.migrated_dashboards) && + result.migrated_dashboards[0]?.id + ) { return result.migrated_dashboards[0].id; } if (Array.isArray(result?.dashboards) && result.dashboards[0]?.id) { @@ -215,7 +236,10 @@ if (failed > 0) { summary.warnings = (result.failed_dashboards || []) .slice(0, 3) - .map((item) => `${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`); + .map( + (item) => + `${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`, + ); } return summary; } @@ -237,17 +261,19 @@ if (Array.isArray(result?.failures) && result.failures.length > 0) { summary.warnings = result.failures .slice(0, 3) - .map((item) => `${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`); + .map( + (item) => + `${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`, + ); } return summary; } if (task.plugin_id === "llm_dashboard_validation") { - summary.targetEnvId = resolveEnvironmentId(params?.environment_id || null); - summary.targetEnvName = resolveEnvironmentName( - summary.targetEnvId, - null, + summary.targetEnvId = resolveEnvironmentId( + params?.environment_id || null, ); + summary.targetEnvName = resolveEnvironmentName(summary.targetEnvId, null); if (result?.summary) { summary.lines.push(result.summary); } @@ -263,7 +289,10 @@ async function handleOpenDashboardDeepLink() { if (!taskSummary?.primaryDashboardId || !taskSummary?.targetEnvId) { - addToast($t.tasks?.summary_link_unavailable || "Deep link unavailable", "error"); + addToast( + $t.tasks?.summary_link_unavailable || "Deep link unavailable", + "error", + ); return; } const href = `/dashboards/${encodeURIComponent(String(taskSummary.primaryDashboardId))}?env_id=${encodeURIComponent(String(taskSummary.targetEnvId))}`; @@ -272,14 +301,19 @@ async function handleShowDiff() { if (!taskSummary?.primaryDashboardId) { - addToast($t.tasks?.summary_link_unavailable || "Diff unavailable", "error"); + addToast( + $t.tasks?.summary_link_unavailable || "Diff unavailable", + "error", + ); return; } showDiff = true; isDiffLoading = true; diffText = ""; try { - const diffPayload = await gitService.getDiff(taskSummary.primaryDashboardId); + const diffPayload = await gitService.getDiff( + taskSummary.primaryDashboardId, + ); diffText = typeof diffPayload === "string" ? diffPayload @@ -295,10 +329,17 @@ function handleOpenLlmReport() { const taskId = normalizeTaskId(activeTaskId); if (!taskId) { - addToast($t.tasks?.summary_link_unavailable || "Report unavailable", "error"); + addToast( + $t.tasks?.summary_link_unavailable || "Report unavailable", + "error", + ); return; } - window.open(`/reports/llm/${encodeURIComponent(taskId)}`, "_blank", "noopener,noreferrer"); + window.open( + `/reports/llm/${encodeURIComponent(taskId)}`, + "_blank", + "noopener,noreferrer", + ); } // Connect to WebSocket for real-time logs @@ -457,7 +498,7 @@ style={`right: ${assistantOffset};`} role="dialog" aria-modal="false" - aria-label={$t.tasks?.drawer } + aria-label={$t.tasks?.drawer} >
{/if}

- {activeTaskId - ? $t.tasks?.details_logs - : $t.tasks?.recent } + {activeTaskId ? $t.tasks?.details_logs : $t.tasks?.recent}

{#if shortTaskId} - + {activeTaskValidation.icon} {activeTaskValidation.label} @@ -518,12 +559,12 @@ class="rounded-md border border-slate-300 bg-slate-50 px-2.5 py-1 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-100" on:click={goToReportsPage} > - {$t.nav?.reports } + {$t.nav?.reports} @@ -534,12 +575,16 @@
{#if activeTaskId} {#if taskSummary} -
+

{$t.tasks?.summary_report || "Summary report"}

- + {taskStatus}
@@ -554,7 +599,9 @@ {/if} {#if taskSummary.warnings.length > 0} -
+

{$t.tasks?.observability_warnings || "Warnings"}

@@ -569,13 +616,13 @@
{#if showDiff}
-

+

{$t.tasks?.diff_preview || "Diff preview"}

{#if isDiffLoading} -

{$t.git?.loading_diff || "Loading diff..."}

+

+ {$t.git?.loading_diff || "Loading diff..."} +

{:else if diffText} -
{diffText}
+
{diffText}
{:else} -

{$t.tasks?.no_diff_available || "No diff available"}

+

+ {$t.tasks?.no_diff_available || "No diff available"} +

{/if}
{/if} @@ -625,14 +679,14 @@
-

{$t.tasks?.loading }

+

{$t.tasks?.loading}

{:else if recentTasks.length > 0}

- {$t.tasks?.recent } + {$t.tasks?.recent}

{#each recentTasks as task} {@const taskValidation = resolveLlmValidationStatus(task)} @@ -646,7 +700,7 @@ "N/A"}... {task.plugin_id || $t.common?.unknown }{task.plugin_id || $t.common?.unknown} {task.status || $t.common?.unknown }{task.status || $t.common?.unknown} {#if taskValidation} - + {taskValidation.icon} {taskValidation.label} @@ -685,7 +741,7 @@ strokeWidth={1.6} className="mb-3 text-slate-700" /> -

{$t.tasks?.select_task }

+

{$t.tasks?.select_task}

{/if}
@@ -696,7 +752,7 @@ >

- {$t.tasks?.footer_text } + {$t.tasks?.footer_text}

diff --git a/frontend/src/lib/components/layout/TopNavbar.svelte b/frontend/src/lib/components/layout/TopNavbar.svelte index d3f7e00..be3fdb0 100644 --- a/frontend/src/lib/components/layout/TopNavbar.svelte +++ b/frontend/src/lib/components/layout/TopNavbar.svelte @@ -14,6 +14,20 @@ * @UX_RECOVERY: Click outside closes dropdowns * @UX_TEST: SearchFocused -> {focus: search input, expected: focused style class applied} * @UX_TEST: ActivityClick -> {click: activity button, expected: task drawer opens} + * + * @TEST_CONTRACT Component_TopNavbar -> + * { + * required_props: {}, + * optional_props: {}, + * invariants: [ + * "Displays user menu and handles logical toggling", + * "Initiates global search successfully taking debounce into account", + * "Correctly handles activity notification badge visibility" + * ] + * } + * @TEST_FIXTURE logged_in -> {"user": {"username": "admin"}} + * @TEST_EDGE network_down -> search fetch fails, handles error state + * @TEST_INVARIANT ui_consistency -> verifies: [logged_in] */ import { createEventDispatcher, onMount } from "svelte"; @@ -157,7 +171,8 @@ const tasks = (tasksResponse || []).slice(0, 30); const taskItems = tasks .filter((task) => { - const haystack = `${task?.id || ""} ${task?.plugin_id || ""} ${task?.status || ""}`.toLowerCase(); + const haystack = + `${task?.id || ""} ${task?.plugin_id || ""} ${task?.status || ""}`.toLowerCase(); return q && haystack.includes(q); }) .slice(0, SEARCH_LIMIT) @@ -219,8 +234,12 @@ isSearchLoading = true; showSearchDropdown = true; try { - const [dashboardResponse, datasetResponse, tasksResponse, reportsResponse] = - await Promise.all([ + const [ + dashboardResponse, + datasetResponse, + tasksResponse, + reportsResponse, + ] = await Promise.all([ api.getDashboards(globalSelectedEnvId, { search: normalizedQuery, page: 1, @@ -248,7 +267,10 @@ normalizedQuery, ); } catch (error) { - console.error("[TopNavbar][Coherence:Failed] Global search failed", error); + console.error( + "[TopNavbar][Coherence:Failed] Global search failed", + error, + ); groupedSearchResults = []; } finally { isSearchLoading = false; @@ -318,7 +340,7 @@ @@ -328,35 +350,47 @@ href="/" class="flex items-center text-xl font-bold text-slate-800 transition-colors hover:text-primary" > - + - {$t.common?.brand } + {$t.common?.brand}
-
{$t.dashboard?.selected_dashboards}
{$t.dashboard?.schedule }{$t.dashboard?.schedule}
{#if backupSchedule !== ""}
{$t.dashboard?.cron_expression} {$t.dashboard?.cron_help}
{/if} @@ -1632,8 +1669,7 @@ {$t.common?.cancel}
diff --git a/frontend/src/routes/dashboards/[id]/+page.svelte b/frontend/src/routes/dashboards/[id]/+page.svelte index ab8bb28..bb55f60 100644 --- a/frontend/src/routes/dashboards/[id]/+page.svelte +++ b/frontend/src/routes/dashboards/[id]/+page.svelte @@ -8,6 +8,18 @@ * @INVARIANT: Shows dashboard metadata, charts, and datasets for selected environment * @TEST_DATA: dashboard_detail_ready -> {"dashboard":{"id":11,"title":"Ops","chart_count":3,"dataset_count":2},"taskHistory":[{"id":"t-1","plugin_id":"llm_dashboard_validation","status":"SUCCESS"}],"llmStatus":{"configured":true,"reason":"ok"}} * @TEST_DATA: llm_unconfigured -> {"llmStatus":{"configured":false,"reason":"invalid_api_key"}} + * + * @TEST_CONTRACT Page_DashboardDetail -> + * { + * required_props: {}, + * optional_props: {}, + * invariants: [ + * "Loads specific dashboard details directly via the API leveraging the id parameter", + * "Triggers LLM validation gracefully respecting llmReady status" + * ] + * } + * @TEST_FIXTURE init_state -> {"id": "1", "env_id": "env1"} + * @TEST_INVARIANT dashboard_fetching -> verifies: [init_state] */ import { onMount, onDestroy } from "svelte"; @@ -55,7 +67,7 @@ async function loadDashboardDetail() { if (!dashboardId || !envId) { - error = $t.dashboard?.missing_context ; + error = $t.dashboard?.missing_context; isLoading = false; return; } @@ -65,7 +77,7 @@ try { dashboard = await api.getDashboardDetail(envId, dashboardId); } catch (err) { - error = err.message || $t.dashboard?.load_detail_failed ; + error = err.message || $t.dashboard?.load_detail_failed; console.error("[DashboardDetail][Coherence:Failed]", err); } finally { isLoading = false; @@ -77,7 +89,9 @@ isTaskHistoryLoading = true; taskHistoryError = null; try { - const response = await api.getDashboardTaskHistory(envId, dashboardId, { limit: 30 }); + const response = await api.getDashboardTaskHistory(envId, dashboardId, { + limit: 30, + }); taskHistory = response?.items || []; } catch (err) { taskHistoryError = err.message || "Failed to load task history"; @@ -99,14 +113,20 @@ isThumbnailLoading = true; thumbnailError = null; try { - const blob = await api.getDashboardThumbnail(envId, dashboardId, { force }); + const blob = await api.getDashboardThumbnail(envId, dashboardId, { + force, + }); releaseThumbnailUrl(); thumbnailUrl = URL.createObjectURL(blob); } catch (err) { if (err?.status === 202) { - thumbnailError = $t.dashboard?.thumbnail_generating || "Thumbnail is being generated"; + thumbnailError = + $t.dashboard?.thumbnail_generating || "Thumbnail is being generated"; } else { - thumbnailError = err.message || $t.dashboard?.thumbnail_failed || "Failed to load thumbnail"; + thumbnailError = + err.message || + $t.dashboard?.thumbnail_failed || + "Failed to load thumbnail"; } } finally { isThumbnailLoading = false; @@ -124,11 +144,19 @@ const taskId = response?.task_id; if (taskId) { openDrawerForTask(taskId); - addToast($t.dashboard?.backup_started || "Backup task started", "success"); + addToast( + $t.dashboard?.backup_started || "Backup task started", + "success", + ); } await loadTaskHistory(); } catch (err) { - addToast(err.message || $t.dashboard?.backup_task_failed || "Failed to start backup", "error"); + addToast( + err.message || + $t.dashboard?.backup_task_failed || + "Failed to start backup", + "error", + ); } finally { isStartingBackup = false; } @@ -136,7 +164,10 @@ async function runLlmValidationTask() { if (!llmReady) { - addToast($t.dashboard?.llm_not_configured || "LLM is not configured", "error"); + addToast( + $t.dashboard?.llm_not_configured || "LLM is not configured", + "error", + ); return; } if (isStartingValidation || !envId || !dashboardId) return; @@ -152,14 +183,19 @@ const taskId = response?.task_id || response?.id; if (taskId) { openDrawerForTask(taskId); - addToast($t.dashboard?.validation_started || "LLM validation started", "success"); + addToast( + $t.dashboard?.validation_started || "LLM validation started", + "success", + ); } - await Promise.all([ - loadTaskHistory(), - loadThumbnail(true), - ]); + await Promise.all([loadTaskHistory(), loadThumbnail(true)]); } catch (err) { - addToast(err.message || $t.dashboard?.validation_start_failed || "Failed to start LLM validation", "error"); + addToast( + err.message || + $t.dashboard?.validation_start_failed || + "Failed to start LLM validation", + "error", + ); } finally { isStartingValidation = false; } @@ -167,21 +203,29 @@ function openLlmReport(taskId) { if (!taskId) return; - window.open(`/reports/llm/${encodeURIComponent(String(taskId))}`, "_blank", "noopener,noreferrer"); + window.open( + `/reports/llm/${encodeURIComponent(String(taskId))}`, + "_blank", + "noopener,noreferrer", + ); } function toTaskTypeLabel(pluginId) { if (pluginId === "superset-backup") return $t.dashboard?.backup || "Backup"; - if (pluginId === "llm_dashboard_validation") return $t.dashboard?.llm_check || "LLM Check"; + if (pluginId === "llm_dashboard_validation") + return $t.dashboard?.llm_check || "LLM Check"; return pluginId || "-"; } function getTaskStatusClasses(status) { const normalized = (status || "").toLowerCase(); - if (normalized === "running" || normalized === "pending") return "bg-blue-100 text-blue-700"; + if (normalized === "running" || normalized === "pending") + return "bg-blue-100 text-blue-700"; if (normalized === "success") return "bg-emerald-100 text-emerald-700"; - if (normalized === "failed" || normalized === "error") return "bg-rose-100 text-rose-700"; - if (normalized === "awaiting_input" || normalized === "waiting_input") return "bg-amber-100 text-amber-700"; + if (normalized === "failed" || normalized === "error") + return "bg-rose-100 text-rose-700"; + if (normalized === "awaiting_input" || normalized === "waiting_input") + return "bg-amber-100 text-amber-700"; return "bg-slate-100 text-slate-700"; } @@ -205,8 +249,10 @@ function getValidationStatusClasses(level) { if (level === "fail") return "bg-rose-100 text-rose-700 border-rose-200"; if (level === "warn") return "bg-amber-100 text-amber-700 border-amber-200"; - if (level === "pass") return "bg-emerald-100 text-emerald-700 border-emerald-200"; - if (level === "unknown") return "bg-slate-100 text-slate-700 border-slate-200"; + if (level === "pass") + return "bg-emerald-100 text-emerald-700 border-emerald-200"; + if (level === "unknown") + return "bg-slate-100 text-slate-700 border-slate-200"; return "bg-slate-50 text-slate-400 border-slate-200"; } @@ -245,13 +291,14 @@ on:click={goBack} > - {$t.common?.back } + {$t.common?.back}

- {dashboard?.title || ($t.dashboard?.overview )} + {dashboard?.title || $t.dashboard?.overview}

- {$t.common?.id }: {dashboardId}{#if dashboard?.slug} • {dashboard.slug}{/if} + {$t.common?.id}: {dashboardId}{#if dashboard?.slug} + • {dashboard.slug}{/if}

@@ -260,45 +307,65 @@ on:click={runBackupTask} disabled={isStartingBackup} > - {isStartingBackup ? ($t.common?.loading || "Loading...") : ($t.dashboard?.run_backup || "Run backup")} + {isStartingBackup + ? $t.common?.loading || "Loading..." + : $t.dashboard?.run_backup || "Run backup"}
{#if !llmReady} -
-
{$t.dashboard?.llm_not_configured || "LLM is not configured"}
+
+
+ {$t.dashboard?.llm_not_configured || "LLM is not configured"} +
{#if llmStatusReason === "no_active_provider"} - {$t.dashboard?.llm_configure_provider || "No active LLM provider. Configure it in Admin -> LLM Settings."} + {$t.dashboard?.llm_configure_provider || + "No active LLM provider. Configure it in Admin -> LLM Settings."} {:else if llmStatusReason === "invalid_api_key"} - {$t.dashboard?.llm_configure_key || "Invalid LLM API key. Update and save a real key in Admin -> LLM Settings."} + {$t.dashboard?.llm_configure_key || + "Invalid LLM API key. Update and save a real key in Admin -> LLM Settings."} {:else} - {$t.dashboard?.llm_status_unavailable || "LLM status is unavailable. Check settings and backend logs."} + {$t.dashboard?.llm_status_unavailable || + "LLM status is unavailable. Check settings and backend logs."} {/if}
{/if} {#if error} -
+
{error} -
{/if} @@ -306,15 +373,23 @@ {#if isLoading}
{#each Array(3) as _} -
+
{/each}
-
+
{:else if dashboard}
-
+
-

+

{$t.dashboard?.api_thumbnail || "Dashboard thumbnail"}