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 } {$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}
>
Animation plays for 200ms
* @UX_FEEDBACK: Active item highlighted with different background
* @UX_RECOVERY: Click outside on mobile closes overlay
+ *
+ * @TEST_CONTRACT Component_Sidebar ->
+ * {
+ * required_props: {},
+ * optional_props: {},
+ * invariants: [
+ * "Highlights active category and sub-item based on current page URL",
+ * "Toggles sidebar via toggleSidebar store action",
+ * "Closes mobile overlay on click outside"
+ * ]
+ * }
+ * @TEST_FIXTURE idle_state -> {}
+ * @TEST_EDGE mobile_open -> shows mobile overlay mask
+ * @TEST_INVARIANT navigation -> verifies: [idle_state]
*/
import { onMount } from "svelte";
@@ -30,56 +44,52 @@
return [
{
id: "dashboards",
- label: $t.nav?.dashboards ,
+ label: $t.nav?.dashboards,
icon: "dashboard",
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
path: "/dashboards",
- subItems: [
- { label: $t.nav?.overview , path: "/dashboards" },
- ],
+ subItems: [{ label: $t.nav?.overview, path: "/dashboards" }],
},
{
id: "datasets",
- label: $t.nav?.datasets ,
+ label: $t.nav?.datasets,
icon: "database",
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
path: "/datasets",
- subItems: [
- { label: $t.nav?.all_datasets , path: "/datasets" },
- ],
+ subItems: [{ label: $t.nav?.all_datasets, path: "/datasets" }],
},
{
id: "storage",
- label: $t.nav?.storage ,
+ label: $t.nav?.storage,
icon: "storage",
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
path: "/storage",
subItems: [
- { label: $t.nav?.backups , path: "/storage/backups" },
+ { label: $t.nav?.backups, path: "/storage/backups" },
{
- label: $t.nav?.repositories ,
+ label: $t.nav?.repositories,
path: "/storage/repos",
},
],
},
{
id: "reports",
- label: $t.nav?.reports ,
+ label: $t.nav?.reports,
icon: "reports",
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
path: "/reports",
- subItems: [{ label: $t.nav?.reports , path: "/reports" }],
+ subItems: [{ label: $t.nav?.reports, path: "/reports" }],
},
{
id: "admin",
- label: $t.nav?.admin ,
+ label: $t.nav?.admin,
icon: "admin",
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
path: "/admin",
subItems: [
- { label: $t.nav?.admin_users , path: "/admin/users" },
- { label: $t.nav?.admin_roles , path: "/admin/roles" },
- { label: $t.nav?.settings , path: "/settings" },
+ { label: $t.nav?.admin_users, path: "/admin/users" },
+ { label: $t.nav?.admin_roles, path: "/admin/roles" },
+ { label: $t.nav?.settings, path: "/settings" },
],
},
];
@@ -198,10 +208,12 @@
>
{#if isExpanded}
-
+
- {$t.nav?.menu }
+ {$t.nav?.menu}
{:else}
M
@@ -228,7 +240,9 @@
aria-expanded={expandedCategories.has(category.id)}
>
-
+
{#if isExpanded}
@@ -282,10 +296,12 @@
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
on:click={handleToggleClick}
>
-
+
- {$t.nav?.collapse }
+ {$t.nav?.collapse}
{:else}
@@ -293,10 +309,10 @@
- {$t.nav?.expand }
+ {$t.nav?.expand}
{/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 taskSummary?.targetEnvName}
- {($t.tasks?.open_dashboard_target || "Open dashboard in {env}").replace(
- "{env}",
- taskSummary.targetEnvName,
- )}
+ {(
+ $t.tasks?.open_dashboard_target || "Open dashboard in {env}"
+ ).replace("{env}", taskSummary.targetEnvName)}
{:else}
{$t.tasks?.open_dashboard_target_fallback || "Open dashboard"}
{/if}
@@ -598,15 +645,22 @@
{#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}
-
+
{#if showSearchDropdown}
-
+
{#if isSearchLoading}
-
{$t.common?.loading || "Loading..."}
+
+ {$t.common?.loading || "Loading..."}
+
{:else if groupedSearchResults.length === 0}
-
{$t.common?.not_found || "No results found"}
+
+ {$t.common?.not_found || "No results found"}
+
{:else}
{#each groupedSearchResults as section}
-
+
{section.label}
{#each section.items as result}
@@ -365,8 +399,12 @@
on:click={() => openSearchResult(result)}
>
-
{result.title}
-
{result.subtitle}
+
+ {result.title}
+
+
+ {result.subtitle}
+
{/each}
@@ -384,8 +422,8 @@
{#if isProdContext}
-
+
PROD
{/if}
@@ -411,8 +451,8 @@
@@ -425,7 +465,7 @@
(e.key === "Enter" || e.key === " ") && handleActivityClick()}
role="button"
tabindex="0"
- aria-label={$t.common?.activity }
+ aria-label={$t.common?.activity}
>
{#if activeCount > 0}
@@ -445,7 +485,7 @@
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
role="button"
tabindex="0"
- aria-label={$t.common?.user_menu }
+ aria-label={$t.common?.user_menu}
>
{#if user}
- {user?.username || ($t.common?.user )}
+ {user?.username || $t.common?.user}
- {$t.nav?.settings }
+ {$t.nav?.settings}
- {$t.common?.logout }
+ {$t.common?.logout}
diff --git a/frontend/src/lib/components/reports/ReportCard.svelte b/frontend/src/lib/components/reports/ReportCard.svelte
index 2349545..1d8b4c7 100644
--- a/frontend/src/lib/components/reports/ReportCard.svelte
+++ b/frontend/src/lib/components/reports/ReportCard.svelte
@@ -11,6 +11,21 @@
*
* @UX_STATE: Ready -> Card displays summary/status/type.
* @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.
+ *
+ * @TEST_CONTRACT Component_ReportCard ->
+ * {
+ * required_props: {report: Object},
+ * optional_props: {selected: boolean, onselect: function},
+ * invariants: [
+ * "Renders properly even if report fields are missing",
+ * "Fires select event when clicked with the report payload",
+ * "Applies selected styling when selected prop is true"
+ * ]
+ * }
+ * @TEST_FIXTURE valid_report_card -> {"report": {"task_type": "migration", "status": "success", "summary": "Test", "updated_at": "2024-01-01"}}
+ * @TEST_EDGE empty_report_object -> {"report": {}}
+ * @TEST_EDGE random_status -> {"report": {"status": "unknown_status_code"}}
+ * @TEST_INVARIANT render_resilience -> verifies: [valid_report_card, empty_report_object, random_status]
*/
import { createEventDispatcher } from "svelte";
diff --git a/frontend/src/lib/components/reports/ReportDetailPanel.svelte b/frontend/src/lib/components/reports/ReportDetailPanel.svelte
index 041ccf9..b44efc4 100644
--- a/frontend/src/lib/components/reports/ReportDetailPanel.svelte
+++ b/frontend/src/lib/components/reports/ReportDetailPanel.svelte
@@ -10,14 +10,29 @@
*
* @UX_STATE: Ready -> Report detail content visible.
* @UX_RECOVERY: Failed/partial report shows next actions and placeholder-safe diagnostics.
+ *
+ * @TEST_CONTRACT Component_ReportDetailPanel ->
+ * {
+ * required_props: {},
+ * optional_props: {detail: Object},
+ * invariants: [
+ * "Renders properly even if detail or nested report object is null",
+ * "Displays placeholders for missing data fields",
+ * "Renders next_actions list if available either at root or inside report.error_context"
+ * ]
+ * }
+ * @TEST_FIXTURE valid_detail -> {"detail": {"report": {"report_id": "1", "task_type": "migration", "status": "success", "summary": "Done"}, "diagnostics": {"time": 123}, "next_actions": []}}
+ * @TEST_EDGE no_detail_prop -> {"detail": null}
+ * @TEST_EDGE detail_with_error_context -> {"detail": {"report": {"error_context": {"next_actions": ["Retry"]}}}}
+ * @TEST_INVARIANT render_resilience -> verifies: [valid_detail, no_detail_prop, detail_with_error_context]
*/
- import { t } from '$lib/i18n';
+ import { t } from "$lib/i18n";
let { detail = null } = $props();
function notProvided(value) {
- if (value === null || value === undefined || value === '') {
+ if (value === null || value === undefined || value === "") {
return $t.reports?.not_provided;
}
return value;
@@ -32,29 +47,55 @@
-
{$t.reports?.view_details}
+
+ {$t.reports?.view_details}
+
{#if !detail || !detail.report}
{$t.reports?.not_provided}
{:else}
-
{$t.reports?.id}: {notProvided(detail.report.report_id)}
-
{$t.reports?.type}: {notProvided(detail.report.task_type)}
-
{$t.reports?.status}: {notProvided(detail.report.status)}
-
{$t.reports?.summary}: {notProvided(detail.report.summary)}
-
{$t.reports?.updated}: {formatDate(detail.report.updated_at)}
+
+ {$t.reports?.id}:
+ {notProvided(detail.report.report_id)}
+
+
+ {$t.reports?.type}:
+ {notProvided(detail.report.task_type)}
+
+
+ {$t.reports?.status}:
+ {notProvided(detail.report.status)}
+
+
+ {$t.reports?.summary}:
+ {notProvided(detail.report.summary)}
+
+
+ {$t.reports?.updated}:
+ {formatDate(detail.report.updated_at)}
+
-
{$t.reports?.diagnostics}
-
{JSON.stringify(detail.diagnostics || { note: $t.reports?.not_provided }, null, 2)}
+
+ {$t.reports?.diagnostics}
+
+
{JSON.stringify(
+ detail.diagnostics || { note: $t.reports?.not_provided },
+ null,
+ 2,
+ )}
{#if (detail.next_actions && detail.next_actions.length > 0) || (detail.report.error_context && detail.report.error_context.next_actions && detail.report.error_context.next_actions.length > 0)}
-
{$t.reports?.next_actions}
+
+ {$t.reports?.next_actions}
+
- {#each (detail.next_actions && detail.next_actions.length > 0 ? detail.next_actions : detail.report.error_context.next_actions) as action}
+ {#each detail.next_actions && detail.next_actions.length > 0 ? detail.next_actions : detail.report.error_context.next_actions as action}
{action}
{/each}
diff --git a/frontend/src/lib/components/reports/ReportsList.svelte b/frontend/src/lib/components/reports/ReportsList.svelte
index c775fd4..0f8a299 100644
--- a/frontend/src/lib/components/reports/ReportsList.svelte
+++ b/frontend/src/lib/components/reports/ReportsList.svelte
@@ -11,16 +11,30 @@
* @UX_STATE: Ready -> Mixed-type list visible and scannable.
* @UX_FEEDBACK: Click on report emits select event.
* @UX_RECOVERY: Unknown/missing values rendered with explicit placeholders.
+ *
+ * @TEST_CONTRACT Component_ReportsList ->
+ * {
+ * required_props: {},
+ * optional_props: {reports: Array, selectedReportId: string},
+ * invariants: [
+ * "Iterates over reports and renders ReportCard for each",
+ * "Passes down selected prop based on selectedReportId",
+ * "Forwards select events from children"
+ * ]
+ * }
+ * @TEST_FIXTURE renders_list -> {"reports": [{"report_id": "1", "task_type": "migration", "status": "success"}, {"report_id": "2", "task_type": "backup", "status": "failed"}], "selectedReportId": "2"}
+ * @TEST_EDGE empty_list -> {"reports": []}
+ * @TEST_INVARIANT correct_iteration -> verifies: [renders_list, empty_list]
*/
- import { createEventDispatcher } from 'svelte';
- import ReportCard from './ReportCard.svelte';
+ import { createEventDispatcher } from "svelte";
+ import ReportCard from "./ReportCard.svelte";
let { reports = [], selectedReportId = null } = $props();
const dispatch = createEventDispatcher();
function handleSelect(event) {
- dispatch('select', { report: event.detail.report });
+ dispatch("select", { report: event.detail.report });
}
@@ -34,4 +48,4 @@
{/each}
-
\ No newline at end of file
+
diff --git a/frontend/src/lib/components/reports/reportTypeProfiles.js b/frontend/src/lib/components/reports/reportTypeProfiles.js
index 3ddba98..db5ee5b 100644
--- a/frontend/src/lib/components/reports/reportTypeProfiles.js
+++ b/frontend/src/lib/components/reports/reportTypeProfiles.js
@@ -50,6 +50,18 @@ export const REPORT_TYPE_PROFILES = {
// @PURPOSE: Resolve visual profile by task type with guaranteed fallback.
// @PRE: taskType may be known/unknown/empty.
// @POST: Returns one profile object.
+//
+// @TEST_CONTRACT: GetReportTypeProfileModel ->
+// {
+// required_fields: {taskType: string},
+// invariants: [
+// "Returns correct profile for known taskType",
+// "Returns 'unknown' profile for invalid or missing taskType"
+// ]
+// }
+// @TEST_FIXTURE: valid_type -> {"taskType": "migration"}
+// @TEST_EDGE: invalid_type -> {"taskType": "invalid"}
+// @TEST_INVARIANT: fallbacks_to_unknown -> verifies: [invalid_type]
export function getReportTypeProfile(taskType) {
const key = typeof taskType === 'string' ? taskType : 'unknown';
console.log("[reports][ui][getReportTypeProfile][STATE:START]");
diff --git a/frontend/src/lib/stores/taskDrawer.js b/frontend/src/lib/stores/taskDrawer.js
index 11b655e..d50890e 100644
--- a/frontend/src/lib/stores/taskDrawer.js
+++ b/frontend/src/lib/stores/taskDrawer.js
@@ -7,6 +7,19 @@
// @UX_STATE: Closed -> Drawer hidden, no active task
// @UX_STATE: Open -> Drawer visible, logs streaming
// @UX_STATE: InputRequired -> Interactive form rendered in drawer
+//
+// @TEST_CONTRACT: TaskDrawerStore ->
+// {
+// required_fields: {isOpen: boolean, activeTaskId: string|null, resourceTaskMap: Object},
+// invariants: [
+// "Updates isOpen and activeTaskId properly on openDrawerForTask",
+// "Updates isOpen and activeTaskId=null on openDrawer",
+// "Properly sets isOpen=false on closeDrawer",
+// "Maintains mapping in resourceTaskMap correctly via updateResourceTask"
+// ]
+// }
+// @TEST_FIXTURE: valid_store_state -> {"isOpen": true, "activeTaskId": "test_1", "resourceTaskMap": {"res1": {"taskId": "test_1", "status": "RUNNING"}}}
+// @TEST_INVARIANT: state_management -> verifies: [valid_store_state]
import { writable, derived } from 'svelte/store';
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index eb0ed35..a16dc42 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -8,6 +8,17 @@
*
* @UX_STATE: Loading -> Shows loading indicator
* @UX_FEEDBACK: Redirects to /dashboards
+ *
+ * @TEST_CONTRACT Page_Home ->
+ * {
+ * required_props: {},
+ * optional_props: {},
+ * invariants: [
+ * "Redirects to /dashboards on mount"
+ * ]
+ * }
+ * @TEST_FIXTURE init_state -> {}
+ * @TEST_INVARIANT correct_redirect -> verifies: [init_state]
*/
import { onMount } from "svelte";
diff --git a/frontend/src/routes/dashboards/+page.svelte b/frontend/src/routes/dashboards/+page.svelte
index 4dc77f6..f0d80ab 100644
--- a/frontend/src/routes/dashboards/+page.svelte
+++ b/frontend/src/routes/dashboards/+page.svelte
@@ -12,9 +12,23 @@
* @UX_STATE: Error -> Shows error banner with retry button
* @UX_STATE: Selecting -> Checkboxes checked, floating action panel appears
* @UX_STATE: BulkAction-Modal -> Migration or Backup modal open
- * @UX_FEEDBACK: Clicking task status opens Task Drawer
* @UX_FEEDBACK: Floating panel slides up from bottom when items selected
* @UX_RECOVERY: Refresh button reloads dashboard list
+ *
+ * @TEST_CONTRACT Page_DashboardHub ->
+ * {
+ * required_props: {},
+ * optional_props: {},
+ * invariants: [
+ * "Loads specific dashboards for currently selected environment store context",
+ * "Reflects Git sync status explicitly via row badges",
+ * "Submits migration action carrying db_mappings safely"
+ * ]
+ * }
+ * @TEST_FIXTURE init_state -> {}
+ * @TEST_EDGE server_error -> shows error block and retry button
+ * @TEST_EDGE bulk_git_actions -> verifies modal functionality when triggered
+ * @TEST_INVARIANT rendering_and_selection -> verifies: [init_state]
*/
import { onMount } from "svelte";
@@ -152,7 +166,7 @@
// Update selection state
updateSelectionState();
} catch (err) {
- error = err.message || $t.dashboard?.load_failed ;
+ error = err.message || $t.dashboard?.load_failed;
console.error("[DashboardHub][Coherence:Failed]", err);
} finally {
isLoading = false;
@@ -304,9 +318,9 @@
} catch (err) {
console.error("[DashboardHub][Coherence:Failed] Validation failed:", err);
alert(
- ($t.dashboard?.validation_start_failed ) +
+ $t.dashboard?.validation_start_failed +
": " +
- (err.message || $t.dashboard?.unknown_error ),
+ (err.message || $t.dashboard?.unknown_error),
);
} finally {
validatingIds.delete(dashboard.id);
@@ -388,9 +402,7 @@
if (isSubmittingMigrate) return;
if (selectedIds.size === 0) return;
if (!targetEnvId) {
- alert(
- $t.dashboard?.target_env_required ,
- );
+ alert($t.dashboard?.target_env_required);
return;
}
@@ -423,7 +435,7 @@
}
} catch (err) {
console.error("[DashboardHub][Coherence:Failed]", err);
- alert($t.dashboard?.migration_task_failed );
+ alert($t.dashboard?.migration_task_failed);
} finally {
isSubmittingMigrate = false;
}
@@ -460,7 +472,7 @@
}
} catch (err) {
console.error("[DashboardHub][Coherence:Failed]", err);
- alert($t.dashboard?.backup_task_failed );
+ alert($t.dashboard?.backup_task_failed);
} finally {
isSubmittingBackup = false;
}
@@ -546,7 +558,10 @@
try {
const configs = await ensureGitConfigs();
if (!configs.length) {
- addToast($t.git?.no_servers_configured || "No Git config found", "error");
+ addToast(
+ $t.git?.no_servers_configured || "No Git config found",
+ "error",
+ );
return;
}
@@ -554,10 +569,17 @@
const defaultRemote = config?.default_repository
? `${String(config.url || "").replace(/\/$/, "")}/${config.default_repository}.git`
: "";
- const remoteUrl = prompt($t.git?.remote_url || "Remote URL", defaultRemote);
+ const remoteUrl = prompt(
+ $t.git?.remote_url || "Remote URL",
+ defaultRemote,
+ );
if (!remoteUrl) return;
- await gitService.initRepository(dashboard.id, config.id, remoteUrl.trim());
+ await gitService.initRepository(
+ dashboard.id,
+ config.id,
+ remoteUrl.trim(),
+ );
addToast($t.git?.init_success || "Repository initialized", "success");
await refreshDashboardGitState(dashboard.id);
} catch (err) {
@@ -711,14 +733,14 @@
- {$t.nav?.dashboards }
+ {$t.nav?.dashboards}
- {$t.common?.refresh }
+ {$t.common?.refresh}
@@ -733,7 +755,7 @@
class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
on:click={loadDashboards}
>
- {$t.common?.retry }
+ {$t.common?.retry}
{/if}
@@ -775,7 +797,7 @@
>
-
{$t.dashboard?.empty }
+
{$t.dashboard?.empty}
{:else}
@@ -787,8 +809,8 @@
disabled={total === 0}
>
{isAllSelected
- ? $t.dashboard?.deselect_all
- : $t.dashboard?.select_all }
+ ? $t.dashboard?.deselect_all
+ : $t.dashboard?.select_all}
{isAllVisibleSelected
- ? $t.dashboard?.deselect_visible
- : $t.dashboard?.select_visible }
+ ? $t.dashboard?.deselect_visible
+ : $t.dashboard?.select_visible}
{#if selectedIds.size > 0}
- {(
- $t.dashboard?.selected_count
- ).replace("{count}", String(selectedIds.size))}
+ {($t.dashboard?.selected_count).replace(
+ "{count}",
+ String(selectedIds.size),
+ )}
{/if}
@@ -811,7 +834,7 @@
@@ -826,14 +849,14 @@
>
- {$t.dashboard?.title }
+ {$t.dashboard?.title}
- {$t.dashboard?.git_status }
+ {$t.dashboard?.git_status}
-
{$t.dashboard?.last_task }
+
{$t.dashboard?.last_task}
- {$t.dashboard?.actions }
+ {$t.dashboard?.actions}
@@ -856,7 +879,7 @@
navigateToDashboardDetail(dashboard.id)}
- title={$t.dashboard?.open_overview }
+ title={$t.dashboard?.open_overview}
>
{dashboard.title}
@@ -866,19 +889,23 @@
{dashboard.git?.hasRepo
- ? ($t.dashboard?.status_repo || "Repo")
- : ($t.dashboard?.status_no_repo || "No Repo")}
+ ? $t.dashboard?.status_repo || "Repo"
+ : $t.dashboard?.status_no_repo || "No Repo"}
{#if dashboard.git?.hasRepo}
{dashboard.git?.hasChangesForCommit
- ? ($t.dashboard?.status_changes || "Diff")
- : ($t.dashboard?.status_no_changes || "Synced")}
+ ? $t.dashboard?.status_changes || "Diff"
+ : $t.dashboard?.status_no_changes || "Synced"}
{/if}
@@ -895,18 +922,18 @@
handleTaskStatusClick(dashboard)}
role="button"
tabindex="0"
- aria-label={$t.dashboard?.view_task }
+ aria-label={$t.dashboard?.view_task}
>
{@html getTaskStatusIcon(dashboard.lastTask.status)}
{#if dashboard.lastTask.status.toLowerCase() === "running"}
- {$t.dashboard?.task_running }
+ {$t.dashboard?.task_running}
{:else if dashboard.lastTask.status.toLowerCase() === "success"}
- {$t.dashboard?.task_done }
+ {$t.dashboard?.task_done}
{:else if dashboard.lastTask.status.toLowerCase() === "error"}
- {$t.dashboard?.task_failed }
+ {$t.dashboard?.task_failed}
{:else if dashboard.lastTask.status.toLowerCase() === "waiting_input"}
- {$t.dashboard?.task_waiting }
+ {$t.dashboard?.task_waiting}
{/if}
@@ -943,7 +970,14 @@
disabled={isGitBusy(dashboard.id)}
title={$t.git?.sync || "Sync from Superset"}
>
-
+
@@ -951,10 +985,18 @@
handleGitCommit(dashboard)}
- disabled={isGitBusy(dashboard.id) || !dashboard.git?.hasChangesForCommit}
+ disabled={isGitBusy(dashboard.id) ||
+ !dashboard.git?.hasChangesForCommit}
title={$t.git?.commit || "Commit"}
>
-
+
@@ -966,7 +1008,14 @@
disabled={isGitBusy(dashboard.id)}
title={$t.git?.pull || "Pull"}
>
-
+
@@ -978,7 +1027,14 @@
disabled={isGitBusy(dashboard.id)}
title={$t.git?.push || "Push"}
>
-
+
@@ -988,7 +1044,7 @@
handleAction(dashboard, "migrate")}
- title={$t.dashboard?.action_migrate }
+ title={$t.dashboard?.action_migrate}
>
handleValidate(dashboard)}
disabled={validatingIds.has(dashboard.id)}
- title={$t.dashboard?.action_validate }
+ title={$t.dashboard?.action_validate}
>
{#if validatingIds.has(dashboard.id)}
handleAction(dashboard, "backup")}
- title={$t.dashboard?.action_backup }
+ title={$t.dashboard?.action_backup}
>
- {($t.dashboard?.showing )
+ {($t.dashboard?.showing)
.replace("{start}", String((currentPage - 1) * pageSize + 1))
.replace("{end}", String(Math.min(currentPage * pageSize, total)))
.replace("{total}", String(total))}
@@ -1080,14 +1136,14 @@
on:click={() => handlePageChange(1)}
disabled={currentPage === 1}
>
- {$t.common?.first }
+ {$t.common?.first}
handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
- {$t.dashboard?.previous }
+ {$t.dashboard?.previous}
{#each getPaginationRange(currentPage, totalPages) as pageNum}
{#if pageNum === "..."}
@@ -1106,14 +1162,14 @@
on:click={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
- {$t.dashboard?.next }
+ {$t.dashboard?.next}
handlePageChange(totalPages)}
disabled={currentPage === totalPages}
>
- {$t.common?.last }
+ {$t.common?.last}
@@ -1123,34 +1179,19 @@
on:change={handlePageSizeChange}
>
- {($t.dashboard?.per_page_option ).replace(
- "{count}",
- "5",
- )}
+ {($t.dashboard?.per_page_option).replace("{count}", "5")}
- {($t.dashboard?.per_page_option ).replace(
- "{count}",
- "10",
- )}
+ {($t.dashboard?.per_page_option).replace("{count}", "10")}
- {($t.dashboard?.per_page_option ).replace(
- "{count}",
- "25",
- )}
+ {($t.dashboard?.per_page_option).replace("{count}", "25")}
- {($t.dashboard?.per_page_option ).replace(
- "{count}",
- "50",
- )}
+ {($t.dashboard?.per_page_option).replace("{count}", "50")}
- {($t.dashboard?.per_page_option ).replace(
- "{count}",
- "100",
- )}
+ {($t.dashboard?.per_page_option).replace("{count}", "100")}
@@ -1165,9 +1206,10 @@
- ✓ {(
- $t.dashboard?.selected_count
- ).replace("{count}", String(selectedIds.size))}
+ ✓ {($t.dashboard?.selected_count).replace(
+ "{count}",
+ String(selectedIds.size),
+ )}
@@ -1183,19 +1225,19 @@
isEditingMappings = false;
}}
>
- {$t.dashboard?.bulk_migrate }
+ {$t.dashboard?.bulk_migrate}
(showBackupModal = true)}
>
- {$t.dashboard?.bulk_backup }
+ {$t.dashboard?.bulk_backup}
selectedIds.clear()}
>
- {$t.common?.cancel }
+ {$t.common?.cancel}
@@ -1224,14 +1266,15 @@
class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative"
>
- {(
- $t.dashboard?.migrate_modal_title
- ).replace("{count}", String(selectedIds.size))}
+ {($t.dashboard?.migrate_modal_title).replace(
+ "{count}",
+ String(selectedIds.size),
+ )}
(showMigrateModal = false)}
class="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-all"
- aria-label={$t.common?.close_modal }
+ aria-label={$t.common?.close_modal}
>
{$t.migration?.source_env }{$t.migration?.source_env}
{environments.find((e) => e.id === selectedEnv)?.name ||
- selectedEnv} {$t.dashboard?.read_only }
+ selectedEnv}
+ {$t.dashboard?.read_only}
@@ -1272,7 +1316,7 @@
{$t.migration?.target_env } {$t.migration?.target_env}
{$t.migration?.database_mappings }{$t.migration?.database_mappings}
{useDbMappings
- ? $t.common?.on
- : $t.common?.off } {useDbMappings ? $t.common?.on : $t.common?.off}
@@ -1324,8 +1366,8 @@
on:click={() => (isEditingMappings = !isEditingMappings)}
>
{isEditingMappings
- ? $t.dashboard?.view_summary
- : $t.dashboard?.edit_mappings }
+ ? $t.dashboard?.view_summary
+ : $t.dashboard?.edit_mappings}
{/if}
@@ -1353,13 +1395,13 @@
{$t.dashboard?.source_database } {$t.dashboard?.source_database}
{$t.dashboard?.target_database } {$t.dashboard?.target_database}
{$t.dashboard?.match_percent } {$t.dashboard?.match_percent}
@@ -1379,7 +1421,7 @@
)?.database_name || mapping.target_db}
{:else}
{$t.dashboard?.not_mapped } {$t.dashboard?.not_mapped}
{/if}
@@ -1445,7 +1487,7 @@
{$t.dashboard?.selected_dashboards } {$t.dashboard?.selected_dashboards}
(showMigrateModal = false)}
- disabled={isSubmittingMigrate}
- >{$t.common?.cancel }{$t.common?.cancel}
- {isSubmittingMigrate
- ? $t.dashboard?.starting
- : $t.migration?.start }
+ {isSubmittingMigrate ? $t.dashboard?.starting : $t.migration?.start}
@@ -1508,14 +1547,15 @@
class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative"
>
- {(
- $t.dashboard?.backup_modal_title
- ).replace("{count}", String(selectedIds.size))}
+ {($t.dashboard?.backup_modal_title).replace(
+ "{count}",
+ String(selectedIds.size),
+ )}
(showBackupModal = false)}
class="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-all"
- aria-label={$t.common?.close_modal }
+ aria-label={$t.common?.close_modal}
>
{$t.dashboard?.environment }{$t.dashboard?.environment}
{environments.find((e) => e.id === selectedEnv)?.name ||
- selectedEnv} {$t.dashboard?.read_only }
+ selectedEnv}
+ {$t.dashboard?.read_only}
{$t.dashboard?.selected_dashboards } {$t.dashboard?.selected_dashboards}
{$t.dashboard?.schedule }{$t.dashboard?.schedule}
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"}
- {isStartingValidation ? ($t.common?.loading || "Loading...") : ($t.dashboard?.run_llm_check || "Run LLM check")}
+ {isStartingValidation
+ ? $t.common?.loading || "Loading..."
+ : $t.dashboard?.run_llm_check || "Run LLM check"}
- {$t.common?.refresh }
+ {$t.common?.refresh}
{#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}
-
- {$t.common?.retry }
+
+ {$t.common?.retry}
{/if}
@@ -306,15 +373,23 @@
{#if isLoading}
{#each Array(3) as _}
-
+
{/each}
-
+
{:else if dashboard}