Compare commits
86 Commits
40e6d8cd4c
...
019-supers
| Author | SHA1 | Date | |
|---|---|---|---|
| d67d24e7e6 | |||
| 01efc9dae1 | |||
| 43814511ee | |||
| db47e4ce55 | |||
| d5a5c3b902 | |||
| 066c37087d | |||
| b40649b9ed | |||
| 197647d97a | |||
| e9e529e322 | |||
| bc3ff29d2f | |||
| eb8ed5da59 | |||
| b6ae41d576 | |||
| cf42de3060 | |||
| 6062712a92 | |||
| 7790a2dc51 | |||
| a58bef5c73 | |||
| 232dd947d8 | |||
| 33966548d7 | |||
| cad6e97464 | |||
| 47a3213fb9 | |||
| 303d7272f8 | |||
| 0711ded532 | |||
| 495857bbee | |||
| df7582a8db | |||
| 3802b0af8c | |||
| 1702f3a5e9 | |||
| 83c24d4b85 | |||
| dd596698e5 | |||
| 0fee26a846 | |||
| 35096b5e23 | |||
| 0299728d72 | |||
| de6ff0d41b | |||
| 260a90aac5 | |||
| 56a1508b38 | |||
| 7c0a601499 | |||
| a5b1bba226 | |||
| 8f13ed3031 | |||
| 305b07bf8b | |||
| 4e1992f489 | |||
| ac7a6cfadc | |||
| 29daebd628 | |||
| 71873b7bb3 | |||
| 68b25c90a8 | |||
| e9b8794f1a | |||
| 6d94d26e40 | |||
| 598dd50d1d | |||
| eacb88a0e3 | |||
| 10676b7029 | |||
| 2023f6c211 | |||
| 2111c12d0a | |||
| b46133e4c1 | |||
| 6cc2fb4c9b | |||
| c406f71988 | |||
| 55bdd981b1 | |||
| 15843a4607 | |||
| 8b81bb9f1f | |||
| 7f244a8252 | |||
| c0505b4d4f | |||
| 1b863bea1b | |||
| 7c6c959774 | |||
| 554e1128b8 | |||
| 55ca476972 | |||
| 4b4d23e671 | |||
| e80369c8b5 | |||
| ffe942c9dd | |||
| 19744796e4 | |||
| a6bebe295c | |||
| e2ce346b7b | |||
| 789e5a90e3 | |||
| 163d03e6f5 | |||
| 169237b31b | |||
| 45bb8c5429 | |||
| 17c28433bd | |||
| 077daa0245 | |||
| d38cda09dd | |||
| 1a893c0bc0 | |||
| 40ed375aa4 | |||
| 5fdc92fcdf | |||
| e83328b4ff | |||
| 687f4ce565 | |||
| dc9e9e0588 | |||
| 2de3e53ab2 | |||
| 40ea0580d9 | |||
| 8da906738b | |||
| d5a1c0e091 | |||
| ef7a0fcf92 |
@@ -2,12 +2,12 @@
|
||||
|
||||
> High-level module structure for AI Context. Generated automatically.
|
||||
|
||||
**Generated:** 2026-02-23T14:44:08.540853
|
||||
**Generated:** 2026-02-20T11:30:24.325166
|
||||
|
||||
## Summary
|
||||
|
||||
- **Total Modules:** 70
|
||||
- **Total Entities:** 1337
|
||||
- **Total Modules:** 63
|
||||
- **Total Entities:** 1214
|
||||
|
||||
## Module Hierarchy
|
||||
|
||||
@@ -79,9 +79,9 @@
|
||||
### 📁 `routes/`
|
||||
|
||||
- 🏗️ **Layers:** API, UI (API), Unknown
|
||||
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 140, TRIVIAL: 3
|
||||
- 📄 **Files:** 16
|
||||
- 📦 **Entities:** 145
|
||||
- 📊 **Tiers:** CRITICAL: 1, STANDARD: 137, TRIVIAL: 3
|
||||
- 📄 **Files:** 15
|
||||
- 📦 **Entities:** 141
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -115,10 +115,10 @@
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 🏗️ **Layers:** API, Domain (Tests)
|
||||
- 📊 **Tiers:** CRITICAL: 3, STANDARD: 16, TRIVIAL: 22
|
||||
- 📄 **Files:** 5
|
||||
- 📦 **Entities:** 41
|
||||
- 🏗️ **Layers:** API
|
||||
- 📊 **Tiers:** STANDARD: 16, TRIVIAL: 2
|
||||
- 📄 **Files:** 2
|
||||
- 📦 **Entities:** 18
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -126,19 +126,13 @@
|
||||
- Unit tests for Dashboards API endpoints
|
||||
- 📦 **backend.src.api.routes.__tests__.test_datasets** (Module)
|
||||
- Unit tests for Datasets API endpoints
|
||||
- 📦 **backend.tests.test_reports_api** (Module) `[CRITICAL]`
|
||||
- Contract tests for GET /api/reports defaults, pagination, an...
|
||||
- 📦 **backend.tests.test_reports_detail_api** (Module) `[CRITICAL]`
|
||||
- Contract tests for GET /api/reports/{report_id} detail endpo...
|
||||
- 📦 **backend.tests.test_reports_openapi_conformance** (Module) `[CRITICAL]`
|
||||
- Validate implemented reports payload shape against OpenAPI-r...
|
||||
|
||||
### 📁 `core/`
|
||||
|
||||
- 🏗️ **Layers:** Core
|
||||
- 📊 **Tiers:** STANDARD: 112, TRIVIAL: 1
|
||||
- 📊 **Tiers:** STANDARD: 109
|
||||
- 📄 **Files:** 9
|
||||
- 📦 **Entities:** 113
|
||||
- 📦 **Entities:** 109
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -165,7 +159,6 @@
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> AppConfigRecord
|
||||
- 🔗 DEPENDS_ON -> ConfigModels
|
||||
- 🔗 DEPENDS_ON -> PyYAML
|
||||
- 🔗 DEPENDS_ON -> sqlalchemy
|
||||
@@ -173,9 +166,9 @@
|
||||
### 📁 `auth/`
|
||||
|
||||
- 🏗️ **Layers:** Core
|
||||
- 📊 **Tiers:** STANDARD: 26
|
||||
- 📊 **Tiers:** STANDARD: 27
|
||||
- 📄 **Files:** 6
|
||||
- 📦 **Entities:** 26
|
||||
- 📦 **Entities:** 27
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -231,9 +224,9 @@
|
||||
### 📁 `task_manager/`
|
||||
|
||||
- 🏗️ **Layers:** Core
|
||||
- 📊 **Tiers:** CRITICAL: 7, STANDARD: 63, TRIVIAL: 8
|
||||
- 📊 **Tiers:** CRITICAL: 7, STANDARD: 63, TRIVIAL: 4
|
||||
- 📄 **Files:** 7
|
||||
- 📦 **Entities:** 78
|
||||
- 📦 **Entities:** 74
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -305,16 +298,14 @@
|
||||
### 📁 `models/`
|
||||
|
||||
- 🏗️ **Layers:** Domain, Model
|
||||
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 24, TRIVIAL: 21
|
||||
- 📄 **Files:** 10
|
||||
- 📦 **Entities:** 47
|
||||
- 📊 **Tiers:** CRITICAL: 1, STANDARD: 15, TRIVIAL: 17
|
||||
- 📄 **Files:** 8
|
||||
- 📦 **Entities:** 33
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **ADGroupMapping** (Class)
|
||||
- Maps an Active Directory group to a local System Role.
|
||||
- ℂ **AppConfigRecord** (Class)
|
||||
- Stores the single source of truth for application configurat...
|
||||
- ℂ **ConnectionConfig** (Class) `[TRIVIAL]`
|
||||
- Stores credentials for external databases used for column ma...
|
||||
- ℂ **DashboardMetadata** (Class) `[TRIVIAL]`
|
||||
@@ -327,16 +318,17 @@
|
||||
- Target Superset environments for dashboard deployment.
|
||||
- ℂ **Environment** (Class)
|
||||
- Represents a Superset instance environment.
|
||||
- ℂ **ErrorContext** (Class)
|
||||
- Error and recovery context for failed/partial reports.
|
||||
- ℂ **FileCategory** (Class) `[TRIVIAL]`
|
||||
- Enumeration of supported file categories in the storage syst...
|
||||
- ℂ **GitRepository** (Class) `[TRIVIAL]`
|
||||
- Tracking for a local Git repository linked to a dashboard.
|
||||
- ℂ **GitServerConfig** (Class) `[TRIVIAL]`
|
||||
- Configuration for a Git server connection.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> Role
|
||||
- 🔗 DEPENDS_ON -> TaskRecord
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.task_manager.models
|
||||
- 🔗 DEPENDS_ON -> sqlalchemy
|
||||
|
||||
### 📁 `__tests__/`
|
||||
@@ -491,9 +483,9 @@
|
||||
### 📁 `scripts/`
|
||||
|
||||
- 🏗️ **Layers:** Scripts, Unknown
|
||||
- 📊 **Tiers:** STANDARD: 17, TRIVIAL: 2
|
||||
- 📄 **Files:** 5
|
||||
- 📦 **Entities:** 19
|
||||
- 📊 **Tiers:** STANDARD: 7, TRIVIAL: 2
|
||||
- 📄 **Files:** 4
|
||||
- 📦 **Entities:** 9
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -501,8 +493,6 @@
|
||||
- CLI tool for creating the initial admin user.
|
||||
- 📦 **backend.src.scripts.init_auth_db** (Module)
|
||||
- Initializes the auth database and creates the necessary tabl...
|
||||
- 📦 **backend.src.scripts.migrate_sqlite_to_postgres** (Module)
|
||||
- Migrates legacy config and task history from SQLite/file sto...
|
||||
- 📦 **backend.src.scripts.seed_permissions** (Module)
|
||||
- Populates the auth database with initial system permissions.
|
||||
- 📦 **test_dataset_dashboard_relations** (Module) `[TRIVIAL]`
|
||||
@@ -558,44 +548,6 @@
|
||||
- 📦 **backend.src.services.__tests__.test_resource_service** (Module)
|
||||
- Unit tests for ResourceService
|
||||
|
||||
### 📁 `reports/`
|
||||
|
||||
- 🏗️ **Layers:** Domain
|
||||
- 📊 **Tiers:** CRITICAL: 5, STANDARD: 15
|
||||
- 📄 **Files:** 3
|
||||
- 📦 **Entities:** 20
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **ReportsService** (Class) `[CRITICAL]`
|
||||
- Service layer for list/detail report retrieval and normaliza...
|
||||
- 📦 **backend.src.services.reports.normalizer** (Module) `[CRITICAL]`
|
||||
- Convert task manager task objects into canonical unified Tas...
|
||||
- 📦 **backend.src.services.reports.report_service** (Module) `[CRITICAL]`
|
||||
- Aggregate, normalize, filter, and paginate task reports for ...
|
||||
- 📦 **backend.src.services.reports.type_profiles** (Module) `[CRITICAL]`
|
||||
- Deterministic mapping of plugin/task identifiers to canonica...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.task_manager.manager.TaskManager
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.task_manager.models.Task
|
||||
- 🔗 DEPENDS_ON -> backend.src.models.report
|
||||
- 🔗 DEPENDS_ON -> backend.src.models.report.TaskType
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.reports.normalizer
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 🏗️ **Layers:** Domain (Tests)
|
||||
- 📊 **Tiers:** CRITICAL: 1, TRIVIAL: 2
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 3
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **backend.tests.test_report_normalizer** (Module) `[CRITICAL]`
|
||||
- Validate unknown task type fallback and partial payload norm...
|
||||
|
||||
### 📁 `tests/`
|
||||
|
||||
- 🏗️ **Layers:** Domain (Tests), Test, Unknown
|
||||
@@ -723,9 +675,9 @@
|
||||
### 📁 `tasks/`
|
||||
|
||||
- 🏗️ **Layers:** UI, Unknown
|
||||
- 📊 **Tiers:** STANDARD: 4, TRIVIAL: 12
|
||||
- 📄 **Files:** 4
|
||||
- 📦 **Entities:** 16
|
||||
- 📊 **Tiers:** STANDARD: 4, TRIVIAL: 10
|
||||
- 📄 **Files:** 3
|
||||
- 📦 **Entities:** 14
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -739,8 +691,6 @@
|
||||
- Auto-generated module for frontend/src/components/tasks/LogF...
|
||||
- 📦 **TaskLogPanel** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/components/tasks/Task...
|
||||
- 📦 **TaskResultPanel** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/components/tasks/Task...
|
||||
|
||||
### 📁 `tools/`
|
||||
|
||||
@@ -782,22 +732,6 @@
|
||||
- 📦 **toasts_module** (Module)
|
||||
- Manages toast notifications using a Svelte writable store.
|
||||
|
||||
### 📁 `api/`
|
||||
|
||||
- 🏗️ **Layers:** Infra
|
||||
- 📊 **Tiers:** CRITICAL: 1, STANDARD: 4
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 5
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **frontend.src.lib.api.reports** (Module) `[CRITICAL]`
|
||||
- Wrapper-based reports API client for list/detail retrieval w...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> [DEF:api_module]
|
||||
|
||||
### 📁 `auth/`
|
||||
|
||||
- 🏗️ **Layers:** Feature
|
||||
@@ -813,9 +747,9 @@
|
||||
### 📁 `layout/`
|
||||
|
||||
- 🏗️ **Layers:** UI, Unknown
|
||||
- 📊 **Tiers:** CRITICAL: 3, STANDARD: 4, TRIVIAL: 26
|
||||
- 📊 **Tiers:** CRITICAL: 3, STANDARD: 4, TRIVIAL: 23
|
||||
- 📄 **Files:** 4
|
||||
- 📦 **Entities:** 33
|
||||
- 📦 **Entities:** 30
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -836,80 +770,6 @@
|
||||
- 📦 **TopNavbar** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/lib/components/layout...
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 🏗️ **Layers:** Unknown
|
||||
- 📊 **Tiers:** TRIVIAL: 3
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 3
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **test_breadcrumbs.svelte** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/lib/components/layout...
|
||||
|
||||
### 📁 `reports/`
|
||||
|
||||
- 🏗️ **Layers:** UI, Unknown
|
||||
- 📊 **Tiers:** CRITICAL: 4, STANDARD: 1, TRIVIAL: 10
|
||||
- 📄 **Files:** 4
|
||||
- 📦 **Entities:** 15
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **ReportCard** (Component) `[CRITICAL]`
|
||||
- Render one report with explicit textual type label and profi...
|
||||
- 🧩 **ReportDetailPanel** (Component) `[CRITICAL]`
|
||||
- Display detailed report context with diagnostics and actiona...
|
||||
- 🧩 **ReportsList** (Component) `[CRITICAL]`
|
||||
- Render unified list of normalized reports with canonical min...
|
||||
- 📦 **ReportCard** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/lib/components/report...
|
||||
- 📦 **ReportDetailPanel** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/lib/components/report...
|
||||
- 📦 **ReportsList** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/lib/components/report...
|
||||
- 📦 **frontend.src.lib.components.reports.reportTypeProfiles** (Module) `[CRITICAL]`
|
||||
- Deterministic mapping from report task_type to visual profil...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 🏗️ **Layers:** UI, UI (Tests)
|
||||
- 📊 **Tiers:** CRITICAL: 5, STANDARD: 1, TRIVIAL: 4
|
||||
- 📄 **Files:** 6
|
||||
- 📦 **Entities:** 10
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.report_card.ux** (Module) `[CRITICAL]`
|
||||
- Test UX states and transitions for ReportCard component
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.report_detail.integration** (Module) `[CRITICAL]`
|
||||
- Validate detail-panel behavior for failed reports and recove...
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.report_detail.ux** (Module) `[CRITICAL]`
|
||||
- Test UX states and recovery for ReportDetailPanel component
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.report_type_profiles** (Module) `[CRITICAL]`
|
||||
- Validate report type profile mapping and unknown fallback be...
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.reports_filter_performance** (Module)
|
||||
- Guard test for report filter responsiveness on moderate in-m...
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.reports_page.integration** (Module) `[CRITICAL]`
|
||||
- Integration-style checks for unified mixed-type reports rend...
|
||||
|
||||
### 📁 `fixtures/`
|
||||
|
||||
- 🏗️ **Layers:** UI
|
||||
- 📊 **Tiers:** STANDARD: 1
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 1
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **reports.fixtures** (Module)
|
||||
- Shared frontend fixtures for unified reports states.
|
||||
|
||||
### 📁 `i18n/`
|
||||
|
||||
- 🏗️ **Layers:** Infra
|
||||
@@ -1047,7 +907,6 @@
|
||||
- 📦 **RootLayoutConfig** (Module) `[TRIVIAL]`
|
||||
- Root layout configuration (SPA mode)
|
||||
- 📦 **layout** (Module)
|
||||
- Bind global layout shell and conditional login/full-app rend...
|
||||
|
||||
### 📁 `roles/`
|
||||
|
||||
@@ -1172,20 +1031,6 @@
|
||||
- 🧩 **MappingManagement** (Component)
|
||||
- Page for managing database mappings between environments.
|
||||
|
||||
### 📁 `reports/`
|
||||
|
||||
- 🏗️ **Layers:** UI, Unknown
|
||||
- 📊 **Tiers:** CRITICAL: 1, TRIVIAL: 7
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 8
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **UnifiedReportsPage** (Component) `[CRITICAL]`
|
||||
- Unified reports page with filtering and resilient UX states ...
|
||||
- 📦 **+page** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/routes/reports/+page....
|
||||
|
||||
### 📁 `settings/`
|
||||
|
||||
- 🏗️ **Layers:** UI, Unknown
|
||||
@@ -1235,6 +1080,18 @@
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 3
|
||||
|
||||
### 📁 `tasks/`
|
||||
|
||||
- 🏗️ **Layers:** Page
|
||||
- 📊 **Tiers:** STANDARD: 5
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 5
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **TaskManagementPage** (Component)
|
||||
- Page for managing and monitoring tasks.
|
||||
|
||||
### 📁 `debug/`
|
||||
|
||||
- 🏗️ **Layers:** UI
|
||||
@@ -1353,10 +1210,6 @@ graph TD
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
core-->|USES|backend
|
||||
@@ -1371,14 +1224,11 @@ graph TD
|
||||
utils-->|DEPENDS_ON|backend
|
||||
utils-->|DEPENDS_ON|backend
|
||||
models-->|INHERITS_FROM|backend
|
||||
models-->|DEPENDS_ON|backend
|
||||
models-->|USED_BY|backend
|
||||
models-->|INHERITS_FROM|backend
|
||||
llm_analysis-->|IMPLEMENTS|backend
|
||||
llm_analysis-->|IMPLEMENTS|backend
|
||||
storage-->|DEPENDS_ON|backend
|
||||
scripts-->|READS_FROM|backend
|
||||
scripts-->|READS_FROM|backend
|
||||
scripts-->|USES|backend
|
||||
scripts-->|USES|backend
|
||||
scripts-->|CALLS|backend
|
||||
@@ -1396,20 +1246,5 @@ graph TD
|
||||
services-->|DEPENDS_ON|backend
|
||||
services-->|DEPENDS_ON|backend
|
||||
__tests__-->|TESTS|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
__tests__-->|TESTS|backend
|
||||
tests-->|TESTS|backend
|
||||
reports-->|DEPENDS_ON|lib
|
||||
__tests__-->|TESTS|routes
|
||||
__tests__-->|TESTS|routes
|
||||
__tests__-->|TESTS|lib
|
||||
__tests__-->|TESTS|lib
|
||||
__tests__-->|TESTS|lib
|
||||
__tests__-->|TESTS|routes
|
||||
```
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
- ⬅️ READS_FROM `lib`
|
||||
- ⬅️ READS_FROM `t`
|
||||
- ƒ **spawnTask** (`Function`)
|
||||
- 📝 Execute task creation request and emit user feedback.
|
||||
- 📦 **DashboardTypes** (`Module`) `[TRIVIAL]`
|
||||
- 📝 TypeScript interfaces for Dashboard entities
|
||||
- 🏗️ Layer: Domain
|
||||
@@ -235,11 +234,8 @@
|
||||
- 📦 **frontend.src.lib.stores.__tests__.sidebar** (`Module`)
|
||||
- 📝 Unit tests for sidebar store
|
||||
- 🏗️ Layer: Domain (Tests)
|
||||
- 🔒 Invariant: Sidebar store transitions must be deterministic across desktop/mobile toggles.
|
||||
- ƒ **test_sidebar_initial_state** (`Function`)
|
||||
- 📝 Verify initial sidebar store values when no persisted state is available.
|
||||
- ƒ **test_toggleSidebar** (`Function`)
|
||||
- 📝 Verify desktop sidebar expansion toggles deterministically.
|
||||
- ƒ **test_setActiveItem** (`Function`)
|
||||
- ƒ **test_mobile_functions** (`Function`)
|
||||
- 📦 **frontend.src.lib.stores.__tests__.test_activity** (`Module`)
|
||||
@@ -252,26 +248,12 @@
|
||||
- 📦 **frontend.src.lib.stores.__tests__.test_taskDrawer** (`Module`) `[CRITICAL]`
|
||||
- 📝 Unit tests for task drawer store
|
||||
- 🏗️ Layer: UI
|
||||
- 🔒 Invariant: Store state transitions remain deterministic for open/close and task-status mapping.
|
||||
- 📦 **navigation** (`Mock`)
|
||||
- 📝 Mock for $app/navigation in tests
|
||||
- 📦 **stores** (`Mock`)
|
||||
- 📝 Mock for $app/stores in tests
|
||||
- 📦 **environment** (`Mock`)
|
||||
- 📝 Mock for $app/environment in tests
|
||||
- 📦 **frontend.src.lib.api.reports** (`Module`) `[CRITICAL]`
|
||||
- 📝 Wrapper-based reports API client for list/detail retrieval without direct native fetch usage.
|
||||
- 🏗️ Layer: Infra
|
||||
- 🔒 Invariant: Uses existing api wrapper methods and returns structured errors for UI-state mapping.
|
||||
- 🔗 DEPENDS_ON -> `[DEF:api_module]`
|
||||
- ƒ **buildReportQueryString** (`Function`)
|
||||
- 📝 Build query string for reports list endpoint from filter options.
|
||||
- ƒ **normalizeApiError** (`Function`)
|
||||
- 📝 Convert unknown API exceptions into deterministic UI-consumable error objects.
|
||||
- ƒ **getReports** (`Function`)
|
||||
- 📝 Fetch unified report list using existing request wrapper.
|
||||
- ƒ **getReportDetail** (`Function`)
|
||||
- 📝 Fetch one report detail by report_id.
|
||||
- 🧩 **Select** (`Component`) `[TRIVIAL]`
|
||||
- 📝 Standardized dropdown selection component.
|
||||
- 🏗️ Layer: Atom
|
||||
@@ -322,91 +304,6 @@
|
||||
- 📝 Derived store providing the translation dictionary.
|
||||
- ƒ **_** (`Function`)
|
||||
- 📝 Get translation by key path.
|
||||
- 🧩 **ReportCard** (`Component`) `[CRITICAL]`
|
||||
- 📝 Render one report with explicit textual type label and profile-driven visual variant.
|
||||
- 🏗️ Layer: UI
|
||||
- 🔒 Invariant: Unknown task type always uses fallback profile.
|
||||
- ⚡ Events: select
|
||||
- ⬅️ READS_FROM `lib`
|
||||
- ➡️ WRITES_TO `props`
|
||||
- ➡️ WRITES_TO `derived`
|
||||
- 📦 **ReportCard** (`Module`) `[TRIVIAL]`
|
||||
- 📝 Auto-generated module for frontend/src/lib/components/reports/ReportCard.svelte
|
||||
- 🏗️ Layer: Unknown
|
||||
- ƒ **getStatusClass** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **getStatusLabel** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **formatDate** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **onSelect** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 🧩 **ReportsList** (`Component`) `[CRITICAL]`
|
||||
- 📝 Render unified list of normalized reports with canonical minimum fields.
|
||||
- 🏗️ Layer: UI
|
||||
- 🔒 Invariant: Every rendered row shows task_type label, status, summary, and updated_at.
|
||||
- ⚡ Events: select
|
||||
- ➡️ WRITES_TO `props`
|
||||
- 📦 **ReportsList** (`Module`) `[TRIVIAL]`
|
||||
- 📝 Auto-generated module for frontend/src/lib/components/reports/ReportsList.svelte
|
||||
- 🏗️ Layer: Unknown
|
||||
- ƒ **handleSelect** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **frontend.src.lib.components.reports.reportTypeProfiles** (`Module`) `[CRITICAL]`
|
||||
- 📝 Deterministic mapping from report task_type to visual profile with one fallback.
|
||||
- 🏗️ Layer: UI
|
||||
- 🔒 Invariant: Unknown type always resolves to fallback profile.
|
||||
- 🔗 DEPENDS_ON -> `frontend/src/lib/i18n/index.ts`
|
||||
- ƒ **getReportTypeProfile** (`Function`)
|
||||
- 📝 Resolve visual profile by task type with guaranteed fallback.
|
||||
- 🧩 **ReportDetailPanel** (`Component`) `[CRITICAL]`
|
||||
- 📝 Display detailed report context with diagnostics and actionable recovery guidance.
|
||||
- 🏗️ Layer: UI
|
||||
- 🔒 Invariant: Failed/partial reports surface actionable hints when available.
|
||||
- ⬅️ READS_FROM `lib`
|
||||
- ➡️ WRITES_TO `props`
|
||||
- ⬅️ READS_FROM `t`
|
||||
- 📦 **ReportDetailPanel** (`Module`) `[TRIVIAL]`
|
||||
- 📝 Auto-generated module for frontend/src/lib/components/reports/ReportDetailPanel.svelte
|
||||
- 🏗️ Layer: Unknown
|
||||
- ƒ **notProvided** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **formatDate** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.reports_filter_performance** (`Module`)
|
||||
- 📝 Guard test for report filter responsiveness on moderate in-memory dataset.
|
||||
- 🏗️ Layer: UI (Tests)
|
||||
- ƒ **applyFilters** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **makeDataset** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.reports_page.integration** (`Module`) `[CRITICAL]`
|
||||
- 📝 Integration-style checks for unified mixed-type reports rendering expectations.
|
||||
- 🏗️ Layer: UI (Tests)
|
||||
- 🔒 Invariant: Mixed fixture includes all supported report types in one list.
|
||||
- ƒ **collectVisibleTypeLabels** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.report_type_profiles** (`Module`) `[CRITICAL]`
|
||||
- 📝 Validate report type profile mapping and unknown fallback behavior.
|
||||
- 🏗️ Layer: UI (Tests)
|
||||
- 🔒 Invariant: Unknown task_type always resolves to the fallback profile.
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.report_card.ux** (`Module`) `[CRITICAL]`
|
||||
- 📝 Test UX states and transitions for ReportCard component
|
||||
- 🏗️ Layer: UI
|
||||
- 🔒 Invariant: Each test asserts at least one observable UX contract outcome.
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.report_detail.ux** (`Module`) `[CRITICAL]`
|
||||
- 📝 Test UX states and recovery for ReportDetailPanel component
|
||||
- 🏗️ Layer: UI
|
||||
- 🔒 Invariant: Detail UX tests keep placeholder-safe rendering and recovery visibility verifiable.
|
||||
- 📦 **frontend.src.lib.components.reports.__tests__.report_detail.integration** (`Module`) `[CRITICAL]`
|
||||
- 📝 Validate detail-panel behavior for failed reports and recovery guidance visibility.
|
||||
- 🏗️ Layer: UI (Tests)
|
||||
- 🔒 Invariant: Failed report detail exposes actionable next actions when available.
|
||||
- ƒ **buildFailedDetailFixture** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **reports.fixtures** (`Module`)
|
||||
- 📝 Shared frontend fixtures for unified reports states.
|
||||
- 🏗️ Layer: UI
|
||||
- 🧩 **Sidebar** (`Component`) `[CRITICAL]`
|
||||
- 📝 Persistent left sidebar with resource categories navigation
|
||||
- 🏗️ Layer: UI
|
||||
@@ -417,8 +314,6 @@
|
||||
- 📦 **Sidebar** (`Module`) `[TRIVIAL]`
|
||||
- 📝 Auto-generated module for frontend/src/lib/components/layout/Sidebar.svelte
|
||||
- 🏗️ Layer: Unknown
|
||||
- ƒ **buildCategories** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **handleItemClick** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **handleCategoryToggle** (`Function`) `[TRIVIAL]`
|
||||
@@ -470,8 +365,6 @@
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **formatBreadcrumbLabel** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **getCrumbMeta** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 🧩 **TaskDrawer** (`Component`) `[CRITICAL]`
|
||||
- 📝 Global task drawer for monitoring background operations
|
||||
- 🏗️ Layer: UI
|
||||
@@ -490,21 +383,12 @@
|
||||
- 🏗️ Layer: Unknown
|
||||
- ƒ **handleClose** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **goToReportsPage** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **handleOverlayClick** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **connectWebSocket** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **disconnectWebSocket** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **test_breadcrumbs.svelte** (`Module`) `[TRIVIAL]`
|
||||
- 📝 Auto-generated module for frontend/src/lib/components/layout/__tests__/test_breadcrumbs.svelte.js
|
||||
- 🏗️ Layer: Unknown
|
||||
- ƒ **getBreadcrumbs** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **formatBreadcrumbLabel** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **ErrorPage** (`Page`)
|
||||
- 📝 Global error page displaying HTTP status and messages
|
||||
- 🏗️ Layer: UI
|
||||
@@ -518,9 +402,20 @@
|
||||
- ƒ **load** (`Function`)
|
||||
- 📝 Loads initial plugin data for the dashboard.
|
||||
- 📦 **layout** (`Module`)
|
||||
- 📝 Bind global layout shell and conditional login/full-app rendering.
|
||||
- 🏗️ Layer: UI
|
||||
- 🔒 Invariant: Login route bypasses shell; all other routes are wrapped by ProtectedRoute.
|
||||
- 🧩 **TaskManagementPage** (`Component`)
|
||||
- 📝 Page for managing and monitoring tasks.
|
||||
- 🏗️ Layer: Page
|
||||
- ⬅️ READS_FROM `lib`
|
||||
- ➡️ WRITES_TO `t`
|
||||
- ⬅️ READS_FROM `t`
|
||||
- ƒ **loadInitialData** (`Function`)
|
||||
- 📝 Loads tasks and environments on page initialization.
|
||||
- ƒ **refreshTasks** (`Function`)
|
||||
- 📝 Periodically refreshes the task list.
|
||||
- ƒ **handleSelectTask** (`Function`)
|
||||
- 📝 Updates the selected task ID when a task is clicked.
|
||||
- ƒ **handleRunBackup** (`Function`)
|
||||
- 📝 Triggers a manual backup task for the selected environment.
|
||||
- 📦 **DatasetHub** (`Page`) `[CRITICAL]`
|
||||
- 📝 Dataset Hub - Dedicated hub for datasets with mapping progress
|
||||
- 🏗️ Layer: UI
|
||||
@@ -577,28 +472,6 @@
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **getMappingProgress** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 🧩 **UnifiedReportsPage** (`Component`) `[CRITICAL]`
|
||||
- 📝 Unified reports page with filtering and resilient UX states for mixed task types.
|
||||
- 🏗️ Layer: UI
|
||||
- 🔒 Invariant: List state remains deterministic for active filter set.
|
||||
- ⬅️ READS_FROM `lib`
|
||||
- ⬅️ READS_FROM `t`
|
||||
- ➡️ WRITES_TO `t`
|
||||
- 📦 **+page** (`Module`) `[TRIVIAL]`
|
||||
- 📝 Auto-generated module for frontend/src/routes/reports/+page.svelte
|
||||
- 🏗️ Layer: Unknown
|
||||
- ƒ **buildQuery** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **loadReports** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **hasActiveFilters** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **clearFilters** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **onFilterChange** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **onSelectReport** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 🧩 **LoginPage** (`Component`)
|
||||
- 📝 Provides the user interface for local and ADFS authentication.
|
||||
- 🏗️ Layer: UI
|
||||
@@ -1088,11 +961,6 @@
|
||||
- ➡️ WRITES_TO `derived`
|
||||
- ƒ **formatTime** (`Function`)
|
||||
- 📝 Format ISO timestamp to HH:MM:SS */
|
||||
- 📦 **TaskResultPanel** (`Module`) `[TRIVIAL]`
|
||||
- 📝 Auto-generated module for frontend/src/components/tasks/TaskResultPanel.svelte
|
||||
- 🏗️ Layer: Unknown
|
||||
- ƒ **statusColor** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 🧩 **FileList** (`Component`)
|
||||
- 📝 Displays a table of files with metadata and actions.
|
||||
- 🏗️ Layer: UI
|
||||
@@ -1337,27 +1205,6 @@
|
||||
- 🏗️ Layer: Unknown
|
||||
- ƒ **test_dashboard_dataset_relations** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **backend.src.scripts.migrate_sqlite_to_postgres** (`Module`)
|
||||
- 📝 Migrates legacy config and task history from SQLite/file storage to PostgreSQL.
|
||||
- 🏗️ Layer: Scripts
|
||||
- 🔒 Invariant: Script is idempotent for task_records and app_configurations.
|
||||
- 📦 **Constants** (`Section`)
|
||||
- ƒ **_json_load_if_needed** (`Function`)
|
||||
- 📝 Parses JSON-like values from SQLite TEXT/JSON columns to Python objects.
|
||||
- ƒ **_find_legacy_config_path** (`Function`)
|
||||
- 📝 Resolves the existing legacy config.json path from candidates.
|
||||
- ƒ **_connect_sqlite** (`Function`)
|
||||
- 📝 Opens a SQLite connection with row factory.
|
||||
- ƒ **_ensure_target_schema** (`Function`)
|
||||
- 📝 Ensures required PostgreSQL tables exist before migration.
|
||||
- ƒ **_migrate_config** (`Function`)
|
||||
- 📝 Migrates legacy config.json into app_configurations(global).
|
||||
- ƒ **_migrate_tasks_and_logs** (`Function`)
|
||||
- 📝 Migrates task_records and task_logs from SQLite into PostgreSQL.
|
||||
- ƒ **run_migration** (`Function`)
|
||||
- 📝 Orchestrates migration from SQLite/file to PostgreSQL.
|
||||
- ƒ **main** (`Function`)
|
||||
- 📝 CLI entrypoint.
|
||||
- 📦 **backend.src.scripts.seed_permissions** (`Module`)
|
||||
- 📝 Populates the auth database with initial system permissions.
|
||||
- 🏗️ Layer: Scripts
|
||||
@@ -1466,28 +1313,21 @@
|
||||
- ƒ **_validate_import_file** (`Function`)
|
||||
- 📝 Validates that the file to be imported is a valid ZIP with metadata.yaml.
|
||||
- 📦 **ConfigManagerModule** (`Module`)
|
||||
- 📝 Manages application configuration persisted in database with one-time migration from JSON.
|
||||
- 📝 Manages application configuration, including loading/saving to JSON and CRUD for environments.
|
||||
- 🏗️ Layer: Core
|
||||
- 🔒 Invariant: Configuration must always be valid according to AppConfig model.
|
||||
- 🔗 DEPENDS_ON -> `ConfigModels`
|
||||
- 🔗 DEPENDS_ON -> `AppConfigRecord`
|
||||
- 🔗 CALLS -> `logger`
|
||||
- ℂ **ConfigManager** (`Class`)
|
||||
- 📝 A class to handle application configuration persistence and management.
|
||||
- ƒ **__init__** (`Function`)
|
||||
- 📝 Initializes the ConfigManager.
|
||||
- ƒ **_default_config** (`Function`)
|
||||
- 📝 Returns default application configuration.
|
||||
- ƒ **_load_from_legacy_file** (`Function`)
|
||||
- 📝 Loads legacy configuration from config.json for migration fallback.
|
||||
- ƒ **_get_record** (`Function`)
|
||||
- 📝 Loads config record from DB.
|
||||
- ƒ **_load_config** (`Function`)
|
||||
- 📝 Loads the configuration from DB or performs one-time migration from JSON file.
|
||||
- ƒ **_save_config_to_db** (`Function`)
|
||||
- 📝 Saves the provided configuration object to DB.
|
||||
- 📝 Loads the configuration from disk or creates a default one.
|
||||
- ƒ **_save_config_to_disk** (`Function`)
|
||||
- 📝 Saves the provided configuration object to disk.
|
||||
- ƒ **save** (`Function`)
|
||||
- 📝 Saves the current configuration state to DB.
|
||||
- 📝 Saves the current configuration state to disk.
|
||||
- ƒ **get_config** (`Function`)
|
||||
- 📝 Returns the current configuration.
|
||||
- ƒ **update_global_settings** (`Function`)
|
||||
@@ -1537,14 +1377,14 @@
|
||||
- 📦 **AppConfig** (`DataClass`)
|
||||
- 📝 The root configuration model containing all application settings.
|
||||
- 📦 **backend.src.core.database** (`Module`)
|
||||
- 📝 Configures database connection and session management (PostgreSQL-first).
|
||||
- 📝 Configures the SQLite database connection and session management.
|
||||
- 🏗️ Layer: Core
|
||||
- 🔒 Invariant: A single engine instance is used for the entire application.
|
||||
- 🔗 DEPENDS_ON -> `sqlalchemy`
|
||||
- 📦 **BASE_DIR** (`Variable`)
|
||||
- 📝 Base directory for the backend.
|
||||
- 📝 Base directory for the backend (where .db files should reside).
|
||||
- 📦 **DATABASE_URL** (`Constant`)
|
||||
- 📝 URL for the main application database.
|
||||
- 📝 URL for the main mappings database.
|
||||
- 📦 **TASKS_DATABASE_URL** (`Constant`)
|
||||
- 📝 URL for the tasks execution database.
|
||||
- 📦 **AUTH_DATABASE_URL** (`Constant`)
|
||||
@@ -1569,8 +1409,6 @@
|
||||
- 📝 Dependency for getting a tasks database session.
|
||||
- ƒ **get_auth_db** (`Function`)
|
||||
- 📝 Dependency for getting an authentication database session.
|
||||
- ƒ **_build_engine** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **LoggerModule** (`Module`)
|
||||
- 📝 Configures the application's logging system, including a custom handler for buffering logs and streaming them over WebSockets.
|
||||
- 🏗️ Layer: Core
|
||||
@@ -1693,6 +1531,8 @@
|
||||
- 🏗️ Layer: Core
|
||||
- 🔒 Invariant: Uses bcrypt for hashing with standard work factor.
|
||||
- 🔗 DEPENDS_ON -> `passlib`
|
||||
- 📦 **pwd_context** (`Variable`)
|
||||
- 📝 Passlib CryptContext for password management.
|
||||
- ƒ **verify_password** (`Function`)
|
||||
- 📝 Verifies a plain password against a hashed password.
|
||||
- ƒ **get_password_hash** (`Function`)
|
||||
@@ -1938,12 +1778,6 @@
|
||||
- 📝 Delete all logs for a specific task.
|
||||
- ƒ **delete_logs_for_tasks** (`Function`)
|
||||
- 📝 Delete all logs for multiple tasks.
|
||||
- ƒ **_json_load_if_needed** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_parse_datetime** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_resolve_environment_id** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **json_serializable** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **TaskManagerModule** (`Module`)
|
||||
@@ -1997,8 +1831,6 @@
|
||||
- 📝 Resume a task that is awaiting input with provided passwords.
|
||||
- ƒ **clear_tasks** (`Function`)
|
||||
- 📝 Clears tasks based on status filter (also deletes associated logs).
|
||||
- ƒ **sort_key** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **TaskManagerModels** (`Module`)
|
||||
- 📝 Defines the data models and enumerations used by the Task Manager.
|
||||
- 🏗️ Layer: Core
|
||||
@@ -2337,18 +2169,6 @@
|
||||
- ƒ **download_file** (`Function`)
|
||||
- 📝 Retrieve a file for download.
|
||||
- 🔗 CALLS -> `StoragePlugin.get_file_path`
|
||||
- 📦 **ReportsRouter** (`Module`) `[CRITICAL]`
|
||||
- 📝 FastAPI router for unified task report list and detail retrieval endpoints.
|
||||
- 🏗️ Layer: UI (API)
|
||||
- 🔒 Invariant: Endpoints are read-only and do not trigger long-running tasks.
|
||||
- 🔗 DEPENDS_ON -> `backend.src.services.reports.report_service.ReportsService`
|
||||
- 🔗 DEPENDS_ON -> `backend.src.dependencies`
|
||||
- ƒ **_parse_csv_enum_list** (`Function`)
|
||||
- 📝 Parse comma-separated query value into enum list.
|
||||
- ƒ **list_reports** (`Function`)
|
||||
- 📝 Return paginated unified reports list.
|
||||
- ƒ **get_report_detail** (`Function`)
|
||||
- 📝 Return one normalized report detail with diagnostics and next actions.
|
||||
- 📦 **__init__** (`Module`) `[TRIVIAL]`
|
||||
- 📝 Auto-generated module for backend/src/api/routes/__init__.py
|
||||
- 🏗️ Layer: Unknown
|
||||
@@ -2420,75 +2240,15 @@
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **mock_get_dashboards** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **backend.tests.test_reports_openapi_conformance** (`Module`) `[CRITICAL]`
|
||||
- 📝 Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
|
||||
- 🏗️ Layer: Domain (Tests)
|
||||
- 🔒 Invariant: List and detail payloads include required contract keys.
|
||||
- ƒ **__init__** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **get_all_tasks** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_admin_user** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_task** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **test_reports_list_openapi_required_keys** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **test_reports_detail_openapi_required_keys** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **backend.tests.test_reports_api** (`Module`) `[CRITICAL]`
|
||||
- 📝 Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
|
||||
- 🏗️ Layer: Domain (Tests)
|
||||
- 🔒 Invariant: API response contract contains {items,total,page,page_size,has_next,applied_filters}.
|
||||
- ƒ **__init__** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **get_all_tasks** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_admin_user** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_make_task** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **test_get_reports_default_pagination_contract** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **test_get_reports_filter_and_pagination** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **test_get_reports_handles_mixed_naive_and_aware_datetimes** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **test_get_reports_invalid_filter_returns_400** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **backend.src.api.routes.__tests__.test_datasets** (`Module`)
|
||||
- 📝 Unit tests for Datasets API endpoints
|
||||
- 🏗️ Layer: API
|
||||
- 🔒 Invariant: Endpoint contracts remain stable for success and validation failure paths.
|
||||
- ƒ **test_get_datasets_success** (`Function`)
|
||||
- 📝 Validate successful datasets listing contract for an existing environment.
|
||||
- ƒ **test_get_datasets_env_not_found** (`Function`)
|
||||
- ƒ **test_get_datasets_invalid_pagination** (`Function`)
|
||||
- ƒ **test_map_columns_success** (`Function`)
|
||||
- ƒ **test_map_columns_invalid_source_type** (`Function`)
|
||||
- ƒ **test_generate_docs_success** (`Function`)
|
||||
- 📦 **backend.tests.test_reports_detail_api** (`Module`) `[CRITICAL]`
|
||||
- 📝 Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
|
||||
- 🏗️ Layer: Domain (Tests)
|
||||
- 🔒 Invariant: Detail endpoint tests must keep deterministic assertions for success and not-found contracts.
|
||||
- ƒ **__init__** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **get_all_tasks** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_admin_user** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_make_task** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **test_get_report_detail_success** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **test_get_report_detail_not_found** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **backend.src.models.config** (`Module`)
|
||||
- 📝 Defines database schema for persisted application configuration.
|
||||
- 🏗️ Layer: Domain
|
||||
- 🔗 DEPENDS_ON -> `sqlalchemy`
|
||||
- ℂ **AppConfigRecord** (`Class`)
|
||||
- 📝 Stores the single source of truth for application configuration.
|
||||
- 📦 **backend.src.models.llm** (`Module`)
|
||||
- 📝 SQLAlchemy models for LLM provider configuration and validation results.
|
||||
- 🏗️ Layer: Domain
|
||||
@@ -2538,33 +2298,6 @@
|
||||
- 📝 Represents a mapping between source and target databases.
|
||||
- ℂ **MigrationJob** (`Class`) `[TRIVIAL]`
|
||||
- 📝 Represents a single migration execution job.
|
||||
- 📦 **backend.src.models.report** (`Module`) `[CRITICAL]`
|
||||
- 📝 Canonical report schemas for unified task reporting across heterogeneous task types.
|
||||
- 🏗️ Layer: Domain
|
||||
- 🔒 Invariant: Canonical report fields are always present for every report item.
|
||||
- 🔗 DEPENDS_ON -> `backend.src.core.task_manager.models`
|
||||
- ℂ **TaskType** (`Class`)
|
||||
- 📝 Supported normalized task report types.
|
||||
- ℂ **ReportStatus** (`Class`)
|
||||
- 📝 Supported normalized report status values.
|
||||
- ℂ **ErrorContext** (`Class`)
|
||||
- 📝 Error and recovery context for failed/partial reports.
|
||||
- ℂ **TaskReport** (`Class`)
|
||||
- 📝 Canonical normalized report envelope for one task execution.
|
||||
- ℂ **ReportQuery** (`Class`)
|
||||
- 📝 Query object for server-side report filtering, sorting, and pagination.
|
||||
- ℂ **ReportCollection** (`Class`)
|
||||
- 📝 Paginated collection of normalized task reports.
|
||||
- ℂ **ReportDetailView** (`Class`)
|
||||
- 📝 Detailed report representation including diagnostics and recovery actions.
|
||||
- ƒ **_non_empty_str** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_validate_sort_by** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_validate_sort_order** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **_validate_time_range** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **backend.src.models.storage** (`Module`) `[TRIVIAL]`
|
||||
- 📝 Data models for the storage system.
|
||||
- 🏗️ Layer: Domain
|
||||
@@ -2744,82 +2477,12 @@
|
||||
- 📦 **backend.src.services.__tests__.test_resource_service** (`Module`)
|
||||
- 📝 Unit tests for ResourceService
|
||||
- 🏗️ Layer: Service
|
||||
- 🔒 Invariant: Resource summaries preserve task linkage and status projection behavior.
|
||||
- ƒ **test_get_dashboards_with_status** (`Function`)
|
||||
- 📝 Validate dashboard enrichment includes git/task status projections.
|
||||
- ƒ **test_get_datasets_with_status** (`Function`)
|
||||
- ƒ **test_get_activity_summary** (`Function`)
|
||||
- ƒ **test_get_git_status_for_dashboard_no_repo** (`Function`)
|
||||
- ƒ **test_get_last_task_for_resource** (`Function`)
|
||||
- ƒ **test_extract_resource_name_from_task** (`Function`)
|
||||
- 📦 **backend.src.services.reports.normalizer** (`Module`) `[CRITICAL]`
|
||||
- 📝 Convert task manager task objects into canonical unified TaskReport entities with deterministic fallback behavior.
|
||||
- 🏗️ Layer: Domain
|
||||
- 🔒 Invariant: Unknown task types and partial payloads remain visible via fallback mapping.
|
||||
- 🔗 DEPENDS_ON -> `backend.src.core.task_manager.models.Task`
|
||||
- 🔗 DEPENDS_ON -> `backend.src.models.report`
|
||||
- 🔗 DEPENDS_ON -> `backend.src.services.reports.type_profiles`
|
||||
- ƒ **status_to_report_status** (`Function`)
|
||||
- 📝 Normalize internal task status to canonical report status.
|
||||
- ƒ **build_summary** (`Function`)
|
||||
- 📝 Build deterministic user-facing summary from task payload and status.
|
||||
- ƒ **extract_error_context** (`Function`)
|
||||
- 📝 Extract normalized error context and next actions for failed/partial reports.
|
||||
- ƒ **normalize_task_report** (`Function`)
|
||||
- 📝 Convert one Task to canonical TaskReport envelope.
|
||||
- 📦 **backend.src.services.reports.type_profiles** (`Module`) `[CRITICAL]`
|
||||
- 📝 Deterministic mapping of plugin/task identifiers to canonical report task types and fallback profile metadata.
|
||||
- 🏗️ Layer: Domain
|
||||
- 🔒 Invariant: Unknown input always resolves to TaskType.UNKNOWN with a single fallback profile.
|
||||
- 🔗 DEPENDS_ON -> `backend.src.models.report.TaskType`
|
||||
- 📦 **PLUGIN_TO_TASK_TYPE** (`Data`)
|
||||
- 📝 Maps plugin identifiers to normalized report task types.
|
||||
- 📦 **TASK_TYPE_PROFILES** (`Data`)
|
||||
- 📝 Profile metadata registry for each normalized task type.
|
||||
- ƒ **resolve_task_type** (`Function`)
|
||||
- 📝 Resolve canonical task type from plugin/task identifier with guaranteed fallback.
|
||||
- ƒ **get_type_profile** (`Function`)
|
||||
- 📝 Return deterministic profile metadata for a task type.
|
||||
- 📦 **backend.src.services.reports.report_service** (`Module`) `[CRITICAL]`
|
||||
- 📝 Aggregate, normalize, filter, and paginate task reports for unified list/detail API use cases.
|
||||
- 🏗️ Layer: Domain
|
||||
- 🔒 Invariant: List responses are deterministic and include applied filter echo metadata.
|
||||
- 🔗 DEPENDS_ON -> `backend.src.core.task_manager.manager.TaskManager`
|
||||
- 🔗 DEPENDS_ON -> `backend.src.models.report`
|
||||
- 🔗 DEPENDS_ON -> `backend.src.services.reports.normalizer`
|
||||
- ℂ **ReportsService** (`Class`) `[CRITICAL]`
|
||||
- 📝 Service layer for list/detail report retrieval and normalization.
|
||||
- 🔒 Invariant: Service methods are read-only over task history source.
|
||||
- ƒ **__init__** (`Function`) `[CRITICAL]`
|
||||
- 📝 Initialize service with TaskManager dependency.
|
||||
- 🔒 Invariant: Constructor performs no task mutations.
|
||||
- ƒ **_load_normalized_reports** (`Function`)
|
||||
- 📝 Build normalized reports from all available tasks.
|
||||
- 🔒 Invariant: Every returned item is a TaskReport.
|
||||
- ƒ **_to_utc_datetime** (`Function`)
|
||||
- 📝 Normalize naive/aware datetime values to UTC-aware datetime for safe comparisons.
|
||||
- 🔒 Invariant: Naive datetimes are interpreted as UTC to preserve deterministic ordering/filtering.
|
||||
- ƒ **_datetime_sort_key** (`Function`)
|
||||
- 📝 Produce stable numeric sort key for report timestamps.
|
||||
- 🔒 Invariant: Mixed naive/aware datetimes never raise TypeError.
|
||||
- ƒ **_matches_query** (`Function`)
|
||||
- 📝 Apply query filtering to a report.
|
||||
- 🔒 Invariant: Filter evaluation is side-effect free.
|
||||
- ƒ **_sort_reports** (`Function`)
|
||||
- 📝 Sort reports deterministically according to query settings.
|
||||
- 🔒 Invariant: Sorting criteria are deterministic for equal input.
|
||||
- ƒ **list_reports** (`Function`)
|
||||
- 📝 Return filtered, sorted, paginated report collection.
|
||||
- ƒ **get_report_detail** (`Function`)
|
||||
- 📝 Return one normalized report with timeline/diagnostics/next actions.
|
||||
- 📦 **backend.tests.test_report_normalizer** (`Module`) `[CRITICAL]`
|
||||
- 📝 Validate unknown task type fallback and partial payload normalization behavior.
|
||||
- 🏗️ Layer: Domain (Tests)
|
||||
- 🔒 Invariant: Unknown plugin types are mapped to canonical unknown task type.
|
||||
- ƒ **test_unknown_type_maps_to_unknown_profile** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- ƒ **test_partial_payload_keeps_report_visible_with_placeholders** (`Function`) `[TRIVIAL]`
|
||||
- 📝 Auto-detected function (orphan)
|
||||
- 📦 **BackupPlugin** (`Module`)
|
||||
- 📝 A plugin that provides functionality to back up Superset dashboards.
|
||||
- 🏗️ Layer: App
|
||||
|
||||
@@ -32,7 +32,6 @@ Use these for code generation (Style Transfer).
|
||||
## 3. DOMAIN MAP (Modules)
|
||||
* **Module Map:** `.ai/MODULE_MAP.md` -> `[DEF:Module_Map]`
|
||||
* **Project Map:** `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
|
||||
* **Apache Superset OpenAPI:** `.ai/openapi.json` -> `[DEF:Doc:Superset_OpenAPI]`
|
||||
* **Backend Core:** `backend/src/core` -> `[DEF:Module:Backend_Core]`
|
||||
* **Backend API:** `backend/src/api` -> `[DEF:Module:Backend_API]`
|
||||
* **Frontend Lib:** `frontend/src/lib` -> `[DEF:Module:Frontend_Lib]`
|
||||
|
||||
30933
.ai/openapi.json
30933
.ai/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
<!-- [DEF:FrontendComponentShot:Component] -->
|
||||
<!-- /**
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: Task, Button, Action, UX
|
||||
* @PURPOSE: Action button to spawn a new task with full UX feedback cycle.
|
||||
@@ -18,8 +19,6 @@
|
||||
* @UX_TEST: Idle -> {click: spawnTask, expected: isLoading=true}
|
||||
* @UX_TEST: Success -> {api_resolve: 200, expected: toast.success called}
|
||||
*/
|
||||
-->
|
||||
<script>
|
||||
import { postApi } from "$lib/api.js";
|
||||
import { t } from "$lib/i18n";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
@@ -30,11 +29,6 @@
|
||||
let isLoading = false;
|
||||
|
||||
// [DEF:spawnTask:Function]
|
||||
/**
|
||||
* @purpose Execute task creation request and emit user feedback.
|
||||
* @pre plugin_id is resolved and request params are serializable.
|
||||
* @post isLoading is reset and user receives success/error feedback.
|
||||
*/
|
||||
async function spawnTask() {
|
||||
isLoading = true;
|
||||
console.log("[FrontendComponentShot][Loading] Spawning task...");
|
||||
|
||||
@@ -22,10 +22,6 @@ backend/tests/__pycache__
|
||||
*.pyd
|
||||
*.db
|
||||
*.log
|
||||
.env*
|
||||
coverage/
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
backups
|
||||
semantics
|
||||
specs
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -68,9 +68,3 @@ backend/logs
|
||||
backend/auth.db
|
||||
semantics/reports
|
||||
backend/tasks.db
|
||||
|
||||
# Universal / tooling
|
||||
node_modules/
|
||||
.venv/
|
||||
coverage/
|
||||
*.tmp
|
||||
|
||||
@@ -41,10 +41,6 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
|
||||
- SQLite (existing `tasks.db` for results, `auth.db` for permissions, `mappings.db` or new `plugins.db` for provider config/metadata) (017-llm-analysis-plugin)
|
||||
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing) (019-superset-ux-redesign)
|
||||
- SQLite (tasks.db, auth.db, migrations.db) - no new database tables required (019-superset-ux-redesign)
|
||||
- Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack (020-task-reports-design)
|
||||
- SQLite task/result persistence (existing task DB), filesystem only for existing artifacts (no new primary store required) (020-task-reports-design)
|
||||
- Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui` (001-unify-frontend-style)
|
||||
- N/A (UI styling and component behavior only) (001-unify-frontend-style)
|
||||
|
||||
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
|
||||
|
||||
@@ -65,9 +61,9 @@ cd src; pytest; ruff check .
|
||||
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 001-unify-frontend-style: Added Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui`
|
||||
- 020-task-reports-design: Added Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack
|
||||
- 019-superset-ux-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing)
|
||||
- 017-llm-analysis-plugin: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||
- 016-multi-user-auth: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
# Stage 1: Build frontend static assets
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Stage 2: Runtime image for backend + static frontend
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
@@ -13,9 +25,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||
RUN python -m playwright install --with-deps chromium
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
COPY --from=frontend-build /app/frontend/build /app/frontend/build
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
Binary file not shown.
@@ -53,5 +53,4 @@ itsdangerous
|
||||
email-validator
|
||||
openai
|
||||
playwright
|
||||
tenacity
|
||||
Pillow
|
||||
tenacity
|
||||
@@ -1,23 +1,10 @@
|
||||
# [DEF:backend.src.api.routes.__init__:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: routes, lazy-import, module-registry
|
||||
# @PURPOSE: Provide lazy route module loading to avoid heavyweight imports during tests.
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> importlib
|
||||
# @INVARIANT: Only names listed in __all__ are importable via __getattr__.
|
||||
|
||||
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports', 'assistant']
|
||||
|
||||
|
||||
# [DEF:__getattr__:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Lazily import route module by attribute name.
|
||||
# @PRE: name is module candidate exposed in __all__.
|
||||
# @POST: Returns imported submodule or raises AttributeError.
|
||||
def __getattr__(name):
|
||||
if name in __all__:
|
||||
import importlib
|
||||
return importlib.import_module(f".{name}", __name__)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
# [/DEF:__getattr__:Function]
|
||||
# [/DEF:backend.src.api.routes.__init__:Module]
|
||||
# Lazy loading of route modules to avoid import issues in tests
|
||||
# This allows tests to import routes without triggering all module imports
|
||||
|
||||
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin']
|
||||
|
||||
def __getattr__(name):
|
||||
if name in __all__:
|
||||
import importlib
|
||||
return importlib.import_module(f".{name}", __name__)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, assistant, api, confirmation, status
|
||||
# @PURPOSE: Validate assistant API endpoint logic via direct async handler invocation.
|
||||
# @LAYER: UI (API Tests)
|
||||
# @RELATION: DEPENDS_ON -> backend.src.api.routes.assistant
|
||||
# @INVARIANT: Every test clears assistant in-memory state before execution.
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Force isolated sqlite databases for test module before dependencies import.
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_api.db")
|
||||
os.environ.setdefault("TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_tasks.db")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_auth.db")
|
||||
|
||||
from src.api.routes import assistant as assistant_module
|
||||
from src.models.assistant import (
|
||||
AssistantAuditRecord,
|
||||
AssistantConfirmationRecord,
|
||||
AssistantMessageRecord,
|
||||
)
|
||||
|
||||
|
||||
# [DEF:_run_async:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Execute async endpoint handler in synchronous test context.
|
||||
# @PRE: coroutine is awaitable endpoint invocation.
|
||||
# @POST: Returns coroutine result or raises propagated exception.
|
||||
def _run_async(coroutine):
|
||||
return asyncio.run(coroutine)
|
||||
|
||||
|
||||
# [/DEF:_run_async:Function]
|
||||
# [DEF:_FakeTask:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Lightweight task stub used by assistant API tests.
|
||||
class _FakeTask:
|
||||
def __init__(self, task_id: str, status: str = "RUNNING", user_id: str = "u-admin"):
|
||||
self.id = task_id
|
||||
self.status = status
|
||||
self.user_id = user_id
|
||||
|
||||
|
||||
# [/DEF:_FakeTask:Class]
|
||||
# [DEF:_FakeTaskManager:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Minimal async-compatible TaskManager fixture for deterministic test flows.
|
||||
class _FakeTaskManager:
|
||||
def __init__(self):
|
||||
self._created = []
|
||||
|
||||
async def create_task(self, plugin_id, params, user_id=None):
|
||||
task_id = f"task-{len(self._created) + 1}"
|
||||
task = _FakeTask(task_id=task_id, status="RUNNING", user_id=user_id)
|
||||
self._created.append((plugin_id, params, user_id, task))
|
||||
return task
|
||||
|
||||
def get_task(self, task_id):
|
||||
for _, _, _, task in self._created:
|
||||
if task.id == task_id:
|
||||
return task
|
||||
return None
|
||||
|
||||
def get_tasks(self, limit=20, offset=0):
|
||||
return [x[3] for x in self._created][offset : offset + limit]
|
||||
|
||||
|
||||
# [/DEF:_FakeTaskManager:Class]
|
||||
# [DEF:_FakeConfigManager:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Environment config fixture with dev/prod aliases for parser tests.
|
||||
class _FakeConfigManager:
|
||||
def get_environments(self):
|
||||
return [
|
||||
SimpleNamespace(id="dev", name="Development"),
|
||||
SimpleNamespace(id="prod", name="Production"),
|
||||
]
|
||||
|
||||
|
||||
# [/DEF:_FakeConfigManager:Class]
|
||||
# [DEF:_admin_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build admin principal fixture.
|
||||
# @PRE: Test harness requires authenticated admin-like principal object.
|
||||
# @POST: Returns user stub with Admin role.
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(id="u-admin", username="admin", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_admin_user:Function]
|
||||
# [DEF:_limited_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build non-admin principal fixture.
|
||||
# @PRE: Test harness requires restricted principal for deny scenarios.
|
||||
# @POST: Returns user stub without admin privileges.
|
||||
def _limited_user():
|
||||
role = SimpleNamespace(name="Operator", permissions=[])
|
||||
return SimpleNamespace(id="u-limited", username="limited", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_limited_user:Function]
|
||||
# [DEF:_FakeQuery:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Minimal chainable query object for fake SQLAlchemy-like DB behavior in tests.
|
||||
class _FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self._rows = list(rows)
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
def count(self):
|
||||
return len(self._rows)
|
||||
|
||||
def offset(self, offset):
|
||||
self._rows = self._rows[offset:]
|
||||
return self
|
||||
|
||||
def limit(self, limit):
|
||||
self._rows = self._rows[:limit]
|
||||
return self
|
||||
|
||||
|
||||
# [/DEF:_FakeQuery:Class]
|
||||
# [DEF:_FakeDb:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: In-memory fake database implementing subset of Session interface used by assistant routes.
|
||||
class _FakeDb:
|
||||
def __init__(self):
|
||||
self._messages = []
|
||||
self._confirmations = []
|
||||
self._audit = []
|
||||
|
||||
def add(self, row):
|
||||
table = getattr(row, "__tablename__", "")
|
||||
if table == "assistant_messages":
|
||||
self._messages.append(row)
|
||||
return
|
||||
if table == "assistant_confirmations":
|
||||
self._confirmations.append(row)
|
||||
return
|
||||
if table == "assistant_audit":
|
||||
self._audit.append(row)
|
||||
|
||||
def merge(self, row):
|
||||
table = getattr(row, "__tablename__", "")
|
||||
if table != "assistant_confirmations":
|
||||
self.add(row)
|
||||
return row
|
||||
|
||||
for i, existing in enumerate(self._confirmations):
|
||||
if getattr(existing, "id", None) == getattr(row, "id", None):
|
||||
self._confirmations[i] = row
|
||||
return row
|
||||
self._confirmations.append(row)
|
||||
return row
|
||||
|
||||
def query(self, model):
|
||||
if model is AssistantMessageRecord:
|
||||
return _FakeQuery(self._messages)
|
||||
if model is AssistantConfirmationRecord:
|
||||
return _FakeQuery(self._confirmations)
|
||||
if model is AssistantAuditRecord:
|
||||
return _FakeQuery(self._audit)
|
||||
return _FakeQuery([])
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
def rollback(self):
|
||||
return None
|
||||
|
||||
|
||||
# [/DEF:_FakeDb:Class]
|
||||
# [DEF:_clear_assistant_state:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Reset in-memory assistant registries for isolation between tests.
|
||||
# @PRE: Assistant module globals may contain residues from previous test runs.
|
||||
# @POST: In-memory conversation/confirmation/audit dictionaries are empty.
|
||||
def _clear_assistant_state():
|
||||
assistant_module.CONVERSATIONS.clear()
|
||||
assistant_module.USER_ACTIVE_CONVERSATION.clear()
|
||||
assistant_module.CONFIRMATIONS.clear()
|
||||
assistant_module.ASSISTANT_AUDIT.clear()
|
||||
|
||||
|
||||
# [/DEF:_clear_assistant_state:Function]
|
||||
# [DEF:test_unknown_command_returns_needs_clarification:Function]
|
||||
# @PURPOSE: Unknown command should return clarification state and unknown intent.
|
||||
# @PRE: Fake dependencies provide admin user and deterministic task/config/db services.
|
||||
# @POST: Response state is needs_clarification and no execution side-effect occurs.
|
||||
def test_unknown_command_returns_needs_clarification():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(message="сделай что-нибудь"),
|
||||
current_user=_admin_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
assert response.state == "needs_clarification"
|
||||
assert response.intent["domain"] == "unknown"
|
||||
|
||||
|
||||
# [/DEF:test_unknown_command_returns_needs_clarification:Function]
|
||||
# [DEF:test_non_admin_command_returns_denied:Function]
|
||||
# @PURPOSE: Non-admin user must receive denied state for privileged command.
|
||||
# @PRE: Limited principal executes privileged git branch command.
|
||||
# @POST: Response state is denied and operation is not executed.
|
||||
def test_non_admin_command_returns_denied():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="создай ветку feature/test для дашборда 12"
|
||||
),
|
||||
current_user=_limited_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
assert response.state == "denied"
|
||||
|
||||
|
||||
# [/DEF:test_non_admin_command_returns_denied:Function]
|
||||
# [DEF:test_migration_to_prod_requires_confirmation_and_can_be_confirmed:Function]
|
||||
# @PURPOSE: Migration to prod must require confirmation and then start task after explicit confirm.
|
||||
# @PRE: Admin principal submits dangerous migration command.
|
||||
# @POST: Confirmation endpoint transitions flow to started state with task id.
|
||||
def test_migration_to_prod_requires_confirmation_and_can_be_confirmed():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
first = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 12"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert first.state == "needs_confirmation"
|
||||
assert first.confirmation_id
|
||||
|
||||
second = _run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=first.confirmation_id,
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert second.state == "started"
|
||||
assert second.task_id.startswith("task-")
|
||||
|
||||
|
||||
# [/DEF:test_migration_to_prod_requires_confirmation_and_can_be_confirmed:Function]
|
||||
# [DEF:test_status_query_returns_task_status:Function]
|
||||
# @PURPOSE: Task status command must surface current status text for existing task id.
|
||||
# @PRE: At least one task exists after confirmed operation.
|
||||
# @POST: Status query returns started/success and includes referenced task id.
|
||||
def test_status_query_returns_task_status():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
start = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 10"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
confirm = _run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=start.confirmation_id,
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
task_id = confirm.task_id
|
||||
|
||||
status_resp = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message=f"проверь статус задачи {task_id}"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert status_resp.state in {"started", "success"}
|
||||
assert task_id in status_resp.text
|
||||
|
||||
|
||||
# [/DEF:test_status_query_returns_task_status:Function]
|
||||
# [DEF:test_status_query_without_task_id_returns_latest_user_task:Function]
|
||||
# @PURPOSE: Status command without explicit task_id should resolve to latest task for current user.
|
||||
# @PRE: User has at least one created task in task manager history.
|
||||
# @POST: Response references latest task status without explicit task id in command.
|
||||
def test_status_query_without_task_id_returns_latest_user_task():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
start = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 33"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
_run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=start.confirmation_id,
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
|
||||
status_resp = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="покажи статус последней задачи"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert status_resp.state in {"started", "success"}
|
||||
assert "Последняя задача:" in status_resp.text
|
||||
|
||||
|
||||
# [/DEF:test_status_query_without_task_id_returns_latest_user_task:Function]
|
||||
# [DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function]
|
||||
# @PURPOSE: LLM validation command without resolvable dashboard id must request clarification instead of generic failure.
|
||||
# @PRE: Command intent resolves to run_llm_validation but dashboard id cannot be inferred.
|
||||
# @POST: Assistant response state is needs_clarification with guidance text.
|
||||
def test_llm_validation_missing_dashboard_returns_needs_clarification():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="Я хочу сделать валидацию дашборда test1"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
|
||||
assert response.state == "needs_clarification"
|
||||
assert "Укажите" in response.text or "Missing dashboard_id" in response.text
|
||||
|
||||
|
||||
# [/DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function]
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]
|
||||
@@ -1,306 +0,0 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, assistant, authz, confirmation, rbac
|
||||
# @PURPOSE: Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
|
||||
# @LAYER: UI (API Tests)
|
||||
# @RELATION: DEPENDS_ON -> backend.src.api.routes.assistant
|
||||
# @INVARIANT: Security-sensitive flows fail closed for unauthorized actors.
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Force isolated sqlite databases for test module before dependencies import.
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz.db")
|
||||
os.environ.setdefault("TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_tasks.db")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_auth.db")
|
||||
|
||||
from src.api.routes import assistant as assistant_module
|
||||
from src.models.assistant import (
|
||||
AssistantAuditRecord,
|
||||
AssistantConfirmationRecord,
|
||||
AssistantMessageRecord,
|
||||
)
|
||||
|
||||
|
||||
# [DEF:_run_async:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Execute async endpoint handler in synchronous test context.
|
||||
# @PRE: coroutine is awaitable endpoint invocation.
|
||||
# @POST: Returns coroutine result or raises propagated exception.
|
||||
def _run_async(coroutine):
|
||||
return asyncio.run(coroutine)
|
||||
|
||||
|
||||
# [/DEF:_run_async:Function]
|
||||
# [DEF:_FakeTask:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Lightweight task model used for assistant authz tests.
|
||||
class _FakeTask:
|
||||
def __init__(self, task_id: str, status: str = "RUNNING", user_id: str = "u-admin"):
|
||||
self.id = task_id
|
||||
self.status = status
|
||||
self.user_id = user_id
|
||||
|
||||
|
||||
# [/DEF:_FakeTask:Class]
|
||||
# [DEF:_FakeTaskManager:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Minimal task manager for deterministic operation creation and lookup.
|
||||
class _FakeTaskManager:
|
||||
def __init__(self):
|
||||
self._created = []
|
||||
|
||||
async def create_task(self, plugin_id, params, user_id=None):
|
||||
task_id = f"task-{len(self._created) + 1}"
|
||||
task = _FakeTask(task_id=task_id, status="RUNNING", user_id=user_id)
|
||||
self._created.append((plugin_id, params, user_id, task))
|
||||
return task
|
||||
|
||||
def get_task(self, task_id):
|
||||
for _, _, _, task in self._created:
|
||||
if task.id == task_id:
|
||||
return task
|
||||
return None
|
||||
|
||||
def get_tasks(self, limit=20, offset=0):
|
||||
return [x[3] for x in self._created][offset : offset + limit]
|
||||
|
||||
|
||||
# [/DEF:_FakeTaskManager:Class]
|
||||
# [DEF:_FakeConfigManager:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Provide deterministic environment aliases required by intent parsing.
|
||||
class _FakeConfigManager:
|
||||
def get_environments(self):
|
||||
return [
|
||||
SimpleNamespace(id="dev", name="Development"),
|
||||
SimpleNamespace(id="prod", name="Production"),
|
||||
]
|
||||
|
||||
|
||||
# [/DEF:_FakeConfigManager:Class]
|
||||
# [DEF:_admin_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build admin principal fixture.
|
||||
# @PRE: Test requires privileged principal for risky operations.
|
||||
# @POST: Returns admin-like user stub with Admin role.
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(id="u-admin", username="admin", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_admin_user:Function]
|
||||
# [DEF:_other_admin_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build second admin principal fixture for ownership tests.
|
||||
# @PRE: Ownership mismatch scenario needs distinct authenticated actor.
|
||||
# @POST: Returns alternate admin-like user stub.
|
||||
def _other_admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(id="u-admin-2", username="admin2", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_other_admin_user:Function]
|
||||
# [DEF:_limited_user:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Build limited principal without required assistant execution privileges.
|
||||
# @PRE: Permission denial scenario needs non-admin actor.
|
||||
# @POST: Returns restricted user stub.
|
||||
def _limited_user():
|
||||
role = SimpleNamespace(name="Operator", permissions=[])
|
||||
return SimpleNamespace(id="u-limited", username="limited", roles=[role])
|
||||
|
||||
|
||||
# [/DEF:_limited_user:Function]
|
||||
# [DEF:_FakeQuery:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Minimal chainable query object for fake DB interactions.
|
||||
class _FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self._rows = list(rows)
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
def limit(self, limit):
|
||||
self._rows = self._rows[:limit]
|
||||
return self
|
||||
|
||||
def offset(self, offset):
|
||||
self._rows = self._rows[offset:]
|
||||
return self
|
||||
|
||||
def count(self):
|
||||
return len(self._rows)
|
||||
|
||||
|
||||
# [/DEF:_FakeQuery:Class]
|
||||
# [DEF:_FakeDb:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: In-memory session substitute for assistant route persistence calls.
|
||||
class _FakeDb:
|
||||
def __init__(self):
|
||||
self._messages = []
|
||||
self._confirmations = []
|
||||
self._audit = []
|
||||
|
||||
def add(self, row):
|
||||
table = getattr(row, "__tablename__", "")
|
||||
if table == "assistant_messages":
|
||||
self._messages.append(row)
|
||||
elif table == "assistant_confirmations":
|
||||
self._confirmations.append(row)
|
||||
elif table == "assistant_audit":
|
||||
self._audit.append(row)
|
||||
|
||||
def merge(self, row):
|
||||
if getattr(row, "__tablename__", "") != "assistant_confirmations":
|
||||
self.add(row)
|
||||
return row
|
||||
|
||||
for i, existing in enumerate(self._confirmations):
|
||||
if getattr(existing, "id", None) == getattr(row, "id", None):
|
||||
self._confirmations[i] = row
|
||||
return row
|
||||
self._confirmations.append(row)
|
||||
return row
|
||||
|
||||
def query(self, model):
|
||||
if model is AssistantMessageRecord:
|
||||
return _FakeQuery(self._messages)
|
||||
if model is AssistantConfirmationRecord:
|
||||
return _FakeQuery(self._confirmations)
|
||||
if model is AssistantAuditRecord:
|
||||
return _FakeQuery(self._audit)
|
||||
return _FakeQuery([])
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
def rollback(self):
|
||||
return None
|
||||
|
||||
|
||||
# [/DEF:_FakeDb:Class]
|
||||
# [DEF:_clear_assistant_state:Function]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Reset assistant process-local state between test cases.
|
||||
# @PRE: Assistant globals may contain state from prior tests.
|
||||
# @POST: Assistant in-memory state dictionaries are cleared.
|
||||
def _clear_assistant_state():
|
||||
assistant_module.CONVERSATIONS.clear()
|
||||
assistant_module.USER_ACTIVE_CONVERSATION.clear()
|
||||
assistant_module.CONFIRMATIONS.clear()
|
||||
assistant_module.ASSISTANT_AUDIT.clear()
|
||||
|
||||
|
||||
# [/DEF:_clear_assistant_state:Function]
|
||||
# [DEF:test_confirmation_owner_mismatch_returns_403:Function]
|
||||
# @PURPOSE: Confirm endpoint should reject requests from user that does not own the confirmation token.
|
||||
# @PRE: Confirmation token is created by first admin actor.
|
||||
# @POST: Second actor receives 403 on confirm operation.
|
||||
def test_confirmation_owner_mismatch_returns_403():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
create = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 18"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert create.state == "needs_confirmation"
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=create.confirmation_id,
|
||||
current_user=_other_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
# [/DEF:test_confirmation_owner_mismatch_returns_403:Function]
|
||||
# [DEF:test_expired_confirmation_cannot_be_confirmed:Function]
|
||||
# @PURPOSE: Expired confirmation token should be rejected and not create task.
|
||||
# @PRE: Confirmation token exists and is manually expired before confirm request.
|
||||
# @POST: Confirm endpoint raises 400 and no task is created.
|
||||
def test_expired_confirmation_cannot_be_confirmed():
|
||||
_clear_assistant_state()
|
||||
task_manager = _FakeTaskManager()
|
||||
db = _FakeDb()
|
||||
|
||||
create = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="запусти миграцию с dev на prod для дашборда 19"
|
||||
),
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assistant_module.CONFIRMATIONS[create.confirmation_id].expires_at = datetime.utcnow() - timedelta(minutes=1)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_run_async(
|
||||
assistant_module.confirm_operation(
|
||||
confirmation_id=create.confirmation_id,
|
||||
current_user=_admin_user(),
|
||||
task_manager=task_manager,
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert task_manager.get_tasks(limit=10, offset=0) == []
|
||||
|
||||
|
||||
# [/DEF:test_expired_confirmation_cannot_be_confirmed:Function]
|
||||
# [DEF:test_limited_user_cannot_launch_restricted_operation:Function]
|
||||
# @PURPOSE: Limited user should receive denied state for privileged operation.
|
||||
# @PRE: Restricted user attempts dangerous deploy command.
|
||||
# @POST: Assistant returns denied state and does not execute operation.
|
||||
def test_limited_user_cannot_launch_restricted_operation():
|
||||
_clear_assistant_state()
|
||||
response = _run_async(
|
||||
assistant_module.send_message(
|
||||
request=assistant_module.AssistantMessageRequest(
|
||||
message="задеплой дашборд 88 в production"
|
||||
),
|
||||
current_user=_limited_user(),
|
||||
task_manager=_FakeTaskManager(),
|
||||
config_manager=_FakeConfigManager(),
|
||||
db=_FakeDb(),
|
||||
)
|
||||
)
|
||||
assert response.state == "denied"
|
||||
|
||||
|
||||
# [/DEF:test_limited_user_cannot_launch_restricted_operation:Function]
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module]
|
||||
@@ -146,77 +146,6 @@ def test_get_dashboards_invalid_pagination():
|
||||
# [/DEF:test_get_dashboards_invalid_pagination:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dashboard_detail_success:Function]
|
||||
# @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets
|
||||
def test_get_dashboard_detail_success():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm, \
|
||||
patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
||||
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
mock_perm.return_value = lambda: True
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_dashboard_detail.return_value = {
|
||||
"id": 42,
|
||||
"title": "Revenue Dashboard",
|
||||
"slug": "revenue-dashboard",
|
||||
"url": "/superset/dashboard/42/",
|
||||
"description": "Overview",
|
||||
"last_modified": "2026-02-20T10:00:00+00:00",
|
||||
"published": True,
|
||||
"charts": [
|
||||
{
|
||||
"id": 100,
|
||||
"title": "Revenue by Month",
|
||||
"viz_type": "line",
|
||||
"dataset_id": 7,
|
||||
"last_modified": "2026-02-19T10:00:00+00:00",
|
||||
"overview": "line"
|
||||
}
|
||||
],
|
||||
"datasets": [
|
||||
{
|
||||
"id": 7,
|
||||
"table_name": "fact_revenue",
|
||||
"schema": "mart",
|
||||
"database": "Analytics",
|
||||
"last_modified": "2026-02-18T10:00:00+00:00",
|
||||
"overview": "mart.fact_revenue"
|
||||
}
|
||||
],
|
||||
"chart_count": 1,
|
||||
"dataset_count": 1
|
||||
}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
response = client.get("/api/dashboards/42?env_id=prod")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == 42
|
||||
assert payload["chart_count"] == 1
|
||||
assert payload["dataset_count"] == 1
|
||||
# [/DEF:test_get_dashboard_detail_success:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dashboard_detail_env_not_found:Function]
|
||||
# @TEST: GET /api/dashboards/{id} returns 404 for missing environment
|
||||
def test_get_dashboard_detail_env_not_found():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
mock_config.return_value.get_environments.return_value = []
|
||||
mock_perm.return_value = lambda: True
|
||||
|
||||
response = client.get("/api/dashboards/42?env_id=missing")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
# [/DEF:test_get_dashboard_detail_env_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_migrate_dashboards_success:Function]
|
||||
# @TEST: POST /api/dashboards/migrate creates migration task
|
||||
# @PRE: Valid source_env_id, target_env_id, dashboard_ids
|
||||
@@ -354,4 +283,4 @@ def test_get_database_mappings_success():
|
||||
# [/DEF:test_get_database_mappings_success:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_dashboards:Module]
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_dashboards:Module]
|
||||
@@ -1,10 +1,8 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_datasets:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: datasets, api, tests, pagination, mapping, docs
|
||||
# @PURPOSE: Unit tests for Datasets API endpoints
|
||||
# @LAYER: API
|
||||
# @RELATION: TESTS -> backend.src.api.routes.datasets
|
||||
# @INVARIANT: Endpoint contracts remain stable for success and validation failure paths.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
@@ -16,7 +14,6 @@ client = TestClient(app)
|
||||
|
||||
|
||||
# [DEF:test_get_datasets_success:Function]
|
||||
# @PURPOSE: Validate successful datasets listing contract for an existing environment.
|
||||
# @TEST: GET /api/datasets returns 200 and valid schema
|
||||
# @PRE: env_id exists
|
||||
# @POST: Response matches DatasetsResponse schema
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# [DEF:backend.tests.test_reports_api:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, api, contract, pagination, filtering
|
||||
# @PURPOSE: Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> backend.src.api.routes.reports
|
||||
# @INVARIANT: API response contract contains {items,total,page,page_size,has_next,applied_filters}.
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, tasks):
|
||||
self._tasks = tasks
|
||||
|
||||
def get_all_tasks(self):
|
||||
return self._tasks
|
||||
|
||||
|
||||
def _admin_user():
|
||||
admin_role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(username="test-admin", roles=[admin_role])
|
||||
|
||||
|
||||
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, started_at: datetime, finished_at: datetime = None, result=None):
|
||||
return Task(
|
||||
id=task_id,
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
params={"environment_id": "env-1"},
|
||||
result=result or {"summary": f"{plugin_id} {status.value.lower()}"},
|
||||
)
|
||||
|
||||
|
||||
def test_get_reports_default_pagination_contract():
|
||||
now = datetime.utcnow()
|
||||
tasks = [
|
||||
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=10), now - timedelta(minutes=9)),
|
||||
_make_task("t-2", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=8), now - timedelta(minutes=7)),
|
||||
_make_task("t-3", "llm_dashboard_validation", TaskStatus.RUNNING, now - timedelta(minutes=6), None),
|
||||
]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert set(["items", "total", "page", "page_size", "has_next", "applied_filters"]).issubset(data.keys())
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
assert data["total"] == 3
|
||||
assert isinstance(data["items"], list)
|
||||
assert data["applied_filters"]["sort_by"] == "updated_at"
|
||||
assert data["applied_filters"]["sort_order"] == "desc"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_reports_filter_and_pagination():
|
||||
now = datetime.utcnow()
|
||||
tasks = [
|
||||
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=30), now - timedelta(minutes=29)),
|
||||
_make_task("t-2", "superset-backup", TaskStatus.FAILED, now - timedelta(minutes=20), now - timedelta(minutes=19)),
|
||||
_make_task("t-3", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=10), now - timedelta(minutes=9)),
|
||||
]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports?task_types=backup&statuses=failed&page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 1
|
||||
assert data["has_next"] is False
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["task_type"] == "backup"
|
||||
assert data["items"][0]["status"] == "failed"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_reports_handles_mixed_naive_and_aware_datetimes():
|
||||
naive_now = datetime.utcnow()
|
||||
aware_now = datetime.now(timezone.utc)
|
||||
tasks = [
|
||||
_make_task("t-naive", "superset-backup", TaskStatus.SUCCESS, naive_now - timedelta(minutes=5), naive_now - timedelta(minutes=4)),
|
||||
_make_task("t-aware", "superset-migration", TaskStatus.FAILED, aware_now - timedelta(minutes=3), aware_now - timedelta(minutes=2)),
|
||||
]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports?sort_by=updated_at&sort_order=desc")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_reports_invalid_filter_returns_400():
|
||||
now = datetime.utcnow()
|
||||
tasks = [_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=5), now - timedelta(minutes=4))]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports?task_types=bad_type")
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert "detail" in body
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_reports_api:Module]
|
||||
@@ -1,84 +0,0 @@
|
||||
# [DEF:backend.tests.test_reports_detail_api:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, api, detail, diagnostics
|
||||
# @PURPOSE: Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> backend.src.api.routes.reports
|
||||
# @INVARIANT: Detail endpoint tests must keep deterministic assertions for success and not-found contracts.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, tasks):
|
||||
self._tasks = tasks
|
||||
|
||||
def get_all_tasks(self):
|
||||
return self._tasks
|
||||
|
||||
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(username="test-admin", roles=[role])
|
||||
|
||||
|
||||
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
|
||||
now = datetime.utcnow()
|
||||
return Task(
|
||||
id=task_id,
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
started_at=now - timedelta(minutes=2),
|
||||
finished_at=now - timedelta(minutes=1) if status != TaskStatus.RUNNING else None,
|
||||
params={"environment_id": "env-1"},
|
||||
result=result or {"summary": f"{plugin_id} result"},
|
||||
)
|
||||
|
||||
|
||||
def test_get_report_detail_success():
|
||||
task = _make_task(
|
||||
"detail-1",
|
||||
"superset-migration",
|
||||
TaskStatus.FAILED,
|
||||
result={"error": {"message": "Step failed", "next_actions": ["Check mapping", "Retry"]}},
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports/detail-1")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "report" in data
|
||||
assert data["report"]["report_id"] == "detail-1"
|
||||
assert "diagnostics" in data
|
||||
assert "next_actions" in data
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_report_detail_not_found():
|
||||
task = _make_task("detail-2", "superset-backup", TaskStatus.SUCCESS)
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports/unknown-id")
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_reports_detail_api:Module]
|
||||
@@ -1,81 +0,0 @@
|
||||
# [DEF:backend.tests.test_reports_openapi_conformance:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, openapi, conformance
|
||||
# @PURPOSE: Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> specs/020-task-reports-design/contracts/reports-api.openapi.yaml
|
||||
# @INVARIANT: List and detail payloads include required contract keys.
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, tasks):
|
||||
self._tasks = tasks
|
||||
|
||||
def get_all_tasks(self):
|
||||
return self._tasks
|
||||
|
||||
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(username="test-admin", roles=[role])
|
||||
|
||||
|
||||
def _task(task_id: str, plugin_id: str, status: TaskStatus):
|
||||
now = datetime.utcnow()
|
||||
return Task(
|
||||
id=task_id,
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
started_at=now,
|
||||
finished_at=now if status != TaskStatus.RUNNING else None,
|
||||
params={"environment_id": "env-1"},
|
||||
result={"summary": f"{plugin_id} {status.value.lower()}"},
|
||||
)
|
||||
|
||||
|
||||
def test_reports_list_openapi_required_keys():
|
||||
tasks = [
|
||||
_task("r-1", "superset-backup", TaskStatus.SUCCESS),
|
||||
_task("r-2", "superset-migration", TaskStatus.FAILED),
|
||||
]
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
required = {"items", "total", "page", "page_size", "has_next", "applied_filters"}
|
||||
assert required.issubset(body.keys())
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_reports_detail_openapi_required_keys():
|
||||
tasks = [_task("r-3", "llm_dashboard_validation", TaskStatus.SUCCESS)]
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports/r-3")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert "report" in body
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_reports_openapi_conformance:Module]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,6 @@ from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, get_mapping_service, has_permission
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"])
|
||||
@@ -53,41 +52,6 @@ class DashboardsResponse(BaseModel):
|
||||
total_pages: int
|
||||
# [/DEF:DashboardsResponse:DataClass]
|
||||
|
||||
# [DEF:DashboardChartItem:DataClass]
|
||||
class DashboardChartItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
viz_type: Optional[str] = None
|
||||
dataset_id: Optional[int] = None
|
||||
last_modified: Optional[str] = None
|
||||
overview: Optional[str] = None
|
||||
# [/DEF:DashboardChartItem:DataClass]
|
||||
|
||||
# [DEF:DashboardDatasetItem:DataClass]
|
||||
class DashboardDatasetItem(BaseModel):
|
||||
id: int
|
||||
table_name: str
|
||||
schema: Optional[str] = None
|
||||
database: str
|
||||
last_modified: Optional[str] = None
|
||||
overview: Optional[str] = None
|
||||
# [/DEF:DashboardDatasetItem:DataClass]
|
||||
|
||||
# [DEF:DashboardDetailResponse:DataClass]
|
||||
class DashboardDetailResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
slug: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
last_modified: Optional[str] = None
|
||||
published: Optional[bool] = None
|
||||
charts: List[DashboardChartItem]
|
||||
datasets: List[DashboardDatasetItem]
|
||||
chart_count: int
|
||||
dataset_count: int
|
||||
# [/DEF:DashboardDetailResponse:DataClass]
|
||||
|
||||
# [DEF:get_dashboards:Function]
|
||||
# @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status
|
||||
# @PRE: env_id must be a valid environment ID
|
||||
@@ -168,39 +132,6 @@ async def get_dashboards(
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboards: {str(e)}")
|
||||
# [/DEF:get_dashboards:Function]
|
||||
|
||||
# [DEF:get_dashboard_detail:Function]
|
||||
# @PURPOSE: Fetch detailed dashboard info with related charts and datasets
|
||||
# @PRE: env_id must be valid and dashboard_id must exist
|
||||
# @POST: Returns dashboard detail payload for overview page
|
||||
# @RELATION: CALLS -> SupersetClient.get_dashboard_detail
|
||||
@router.get("/{dashboard_id}", response_model=DashboardDetailResponse)
|
||||
async def get_dashboard_detail(
|
||||
dashboard_id: int,
|
||||
env_id: str,
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
with belief_scope("get_dashboard_detail", f"dashboard_id={dashboard_id}, env_id={env_id}"):
|
||||
environments = config_manager.get_environments()
|
||||
env = next((e for e in environments if e.id == env_id), None)
|
||||
if not env:
|
||||
logger.error(f"[get_dashboard_detail][Coherence:Failed] Environment not found: {env_id}")
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
try:
|
||||
client = SupersetClient(env)
|
||||
detail = client.get_dashboard_detail(dashboard_id)
|
||||
logger.info(
|
||||
f"[get_dashboard_detail][Coherence:OK] Dashboard {dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets"
|
||||
)
|
||||
return DashboardDetailResponse(**detail)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[get_dashboard_detail][Coherence:Failed] Failed to fetch dashboard detail: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard detail: {str(e)}")
|
||||
# [/DEF:get_dashboard_detail:Function]
|
||||
|
||||
# [DEF:MigrateRequest:DataClass]
|
||||
class MigrateRequest(BaseModel):
|
||||
source_env_id: str = Field(..., description="Source environment ID")
|
||||
|
||||
@@ -25,7 +25,6 @@ from src.api.routes.git_schemas import (
|
||||
)
|
||||
from src.services.git_service import GitService
|
||||
from src.core.logger import logger, belief_scope
|
||||
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, normalize_llm_settings
|
||||
|
||||
router = APIRouter(tags=["git"])
|
||||
git_service = GitService()
|
||||
@@ -407,7 +406,6 @@ async def get_repository_diff(
|
||||
async def generate_commit_message(
|
||||
dashboard_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
config_manager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("generate_commit_message"):
|
||||
@@ -447,16 +445,7 @@ async def generate_commit_message(
|
||||
# 4. Generate Message
|
||||
from ...plugins.git.llm_extension import GitLLMExtension
|
||||
extension = GitLLMExtension(client)
|
||||
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
|
||||
git_prompt = llm_settings["prompts"].get(
|
||||
"git_commit_prompt",
|
||||
DEFAULT_LLM_PROMPTS["git_commit_prompt"],
|
||||
)
|
||||
message = await extension.suggest_commit_message(
|
||||
diff,
|
||||
history,
|
||||
prompt_template=git_prompt,
|
||||
)
|
||||
message = await extension.suggest_commit_message(diff, history)
|
||||
|
||||
return {"message": message}
|
||||
except Exception as e:
|
||||
@@ -464,4 +453,4 @@ async def generate_commit_message(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# [/DEF:generate_commit_message:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.git:Module]
|
||||
# [/DEF:backend.src.api.routes.git:Module]
|
||||
@@ -1,131 +0,0 @@
|
||||
# [DEF:ReportsRouter:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: api, reports, list, detail, pagination, filters
|
||||
# @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.reports.report_service.ReportsService
|
||||
# @RELATION: DEPENDS_ON -> backend.src.dependencies
|
||||
# @INVARIANT: Endpoints are read-only and do not trigger long-running tasks.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from ...dependencies import get_task_manager, has_permission
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...core.logger import belief_scope
|
||||
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskType
|
||||
from ...services.reports.report_service import ReportsService
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["Reports"])
|
||||
|
||||
|
||||
# [DEF:_parse_csv_enum_list:Function]
|
||||
# @PURPOSE: Parse comma-separated query value into enum list.
|
||||
# @PRE: raw may be None/empty or comma-separated values.
|
||||
# @POST: Returns enum list or raises HTTP 400 with deterministic machine-readable payload.
|
||||
# @PARAM: raw (Optional[str]) - Comma-separated enum values.
|
||||
# @PARAM: enum_cls (type) - Enum class for validation.
|
||||
# @PARAM: field_name (str) - Query field name for diagnostics.
|
||||
# @RETURN: List - Parsed enum values.
|
||||
def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
|
||||
if raw is None or not raw.strip():
|
||||
return []
|
||||
values = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
parsed = []
|
||||
invalid = []
|
||||
for value in values:
|
||||
try:
|
||||
parsed.append(enum_cls(value))
|
||||
except ValueError:
|
||||
invalid.append(value)
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": f"Invalid values for '{field_name}'",
|
||||
"field": field_name,
|
||||
"invalid_values": invalid,
|
||||
"allowed_values": [item.value for item in enum_cls],
|
||||
},
|
||||
)
|
||||
return parsed
|
||||
# [/DEF:_parse_csv_enum_list:Function]
|
||||
|
||||
|
||||
# [DEF:list_reports:Function]
|
||||
# @PURPOSE: Return paginated unified reports 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.
|
||||
@router.get("", response_model=ReportCollection)
|
||||
async def list_reports(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
task_types: Optional[str] = Query(None, description="Comma-separated task types"),
|
||||
statuses: Optional[str] = Query(None, description="Comma-separated statuses"),
|
||||
time_from: Optional[datetime] = Query(None),
|
||||
time_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None, max_length=200),
|
||||
sort_by: str = Query("updated_at"),
|
||||
sort_order: str = Query("desc"),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("list_reports"):
|
||||
try:
|
||||
parsed_task_types = _parse_csv_enum_list(task_types, TaskType, "task_types")
|
||||
parsed_statuses = _parse_csv_enum_list(statuses, ReportStatus, "statuses")
|
||||
query = ReportQuery(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
task_types=parsed_task_types,
|
||||
statuses=parsed_statuses,
|
||||
time_from=time_from,
|
||||
time_to=time_to,
|
||||
search=search,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": "Invalid query parameters",
|
||||
"code": "INVALID_REPORT_QUERY",
|
||||
"reason": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
service = ReportsService(task_manager)
|
||||
return service.list_reports(query)
|
||||
# [/DEF:list_reports:Function]
|
||||
|
||||
|
||||
# [DEF:get_report_detail:Function]
|
||||
# @PURPOSE: Return one normalized report detail with diagnostics and next actions.
|
||||
# @PRE: authenticated/authorized request and existing report_id.
|
||||
# @POST: returns normalized detail envelope or 404 when report is not found.
|
||||
@router.get("/{report_id}", response_model=ReportDetailView)
|
||||
async def get_report_detail(
|
||||
report_id: str,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("get_report_detail", f"report_id={report_id}"):
|
||||
service = ReportsService(task_manager)
|
||||
detail = service.get_report_detail(report_id)
|
||||
if not detail:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"},
|
||||
)
|
||||
return detail
|
||||
# [/DEF:get_report_detail:Function]
|
||||
|
||||
# [/DEF:ReportsRouter:Module]
|
||||
@@ -16,10 +16,9 @@ from pydantic import BaseModel
|
||||
from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig
|
||||
from ...models.storage import StorageConfig
|
||||
from ...dependencies import get_config_manager, has_permission
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...services.llm_prompt_templates import normalize_llm_settings
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:LoggingConfigResponse:Class]
|
||||
@@ -39,14 +38,13 @@ router = APIRouter()
|
||||
# @POST: Returns masked AppConfig.
|
||||
# @RETURN: AppConfig - The current configuration.
|
||||
@router.get("", response_model=AppConfig)
|
||||
async def get_settings(
|
||||
async def get_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_settings"):
|
||||
logger.info("[get_settings][Entry] Fetching all settings")
|
||||
config = config_manager.get_config().copy(deep=True)
|
||||
config.settings.llm = normalize_llm_settings(config.settings.llm)
|
||||
config = config_manager.get_config().copy(deep=True)
|
||||
# Mask passwords
|
||||
for env in config.environments:
|
||||
if env.password:
|
||||
@@ -281,7 +279,7 @@ async def update_logging_config(
|
||||
# [/DEF:update_logging_config:Function]
|
||||
|
||||
# [DEF:ConsolidatedSettingsResponse:Class]
|
||||
class ConsolidatedSettingsResponse(BaseModel):
|
||||
class ConsolidatedSettingsResponse(BaseModel):
|
||||
environments: List[dict]
|
||||
connections: List[dict]
|
||||
llm: dict
|
||||
@@ -296,7 +294,7 @@ class ConsolidatedSettingsResponse(BaseModel):
|
||||
# @POST: Returns all consolidated settings.
|
||||
# @RETURN: ConsolidatedSettingsResponse - All settings categories.
|
||||
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
|
||||
async def get_consolidated_settings(
|
||||
async def get_consolidated_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
@@ -325,16 +323,14 @@ async def get_consolidated_settings(
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
normalized_llm = normalize_llm_settings(config.settings.llm)
|
||||
|
||||
return ConsolidatedSettingsResponse(
|
||||
environments=[env.dict() for env in config.environments],
|
||||
connections=config.settings.connections,
|
||||
llm=normalized_llm,
|
||||
llm_providers=llm_providers_list,
|
||||
logging=config.settings.logging.dict(),
|
||||
storage=config.settings.storage.dict()
|
||||
)
|
||||
return ConsolidatedSettingsResponse(
|
||||
environments=[env.dict() for env in config.environments],
|
||||
connections=config.settings.connections,
|
||||
llm=config.settings.llm,
|
||||
llm_providers=llm_providers_list,
|
||||
logging=config.settings.logging.dict(),
|
||||
storage=config.settings.storage.dict()
|
||||
)
|
||||
# [/DEF:get_consolidated_settings:Function]
|
||||
|
||||
# [DEF:update_consolidated_settings:Function]
|
||||
@@ -357,9 +353,9 @@ async def update_consolidated_settings(
|
||||
if "connections" in settings_patch:
|
||||
current_settings.connections = settings_patch["connections"]
|
||||
|
||||
# Update LLM if provided
|
||||
if "llm" in settings_patch:
|
||||
current_settings.llm = normalize_llm_settings(settings_patch["llm"])
|
||||
# Update LLM if provided
|
||||
if "llm" in settings_patch:
|
||||
current_settings.llm = settings_patch["llm"]
|
||||
|
||||
# Update Logging if provided
|
||||
if "logging" in settings_patch:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: Depends on the TaskManager. It is included by the main app.
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from pydantic import BaseModel
|
||||
from ...core.logger import belief_scope
|
||||
@@ -13,15 +13,9 @@ from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
||||
from ...core.task_manager.models import LogFilter, LogStats
|
||||
from ...dependencies import get_task_manager, has_permission, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TASK_TYPE_PLUGIN_MAP = {
|
||||
"llm_validation": ["llm_dashboard_validation"],
|
||||
"backup": ["superset-backup"],
|
||||
"migration": ["superset-migration"],
|
||||
}
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
router = APIRouter()
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
plugin_id: str
|
||||
params: Dict[str, Any]
|
||||
|
||||
@@ -85,36 +79,18 @@ async def create_task(
|
||||
# @PRE: task_manager must be available.
|
||||
# @POST: Returns a list of tasks.
|
||||
# @RETURN: List[Task] - List of tasks.
|
||||
async def list_tasks(
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status_filter: Optional[TaskStatus] = Query(None, alias="status"),
|
||||
task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
|
||||
plugin_id: Optional[List[str]] = Query(None, description="Filter by plugin_id (repeatable query param)"),
|
||||
completed_only: bool = Query(False, description="Return only completed tasks (SUCCESS/FAILED)"),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_ = Depends(has_permission("tasks", "READ"))
|
||||
):
|
||||
"""
|
||||
Retrieve a list of tasks with pagination and optional status filter.
|
||||
"""
|
||||
with belief_scope("list_tasks"):
|
||||
plugin_filters = list(plugin_id) if plugin_id else []
|
||||
if task_type:
|
||||
if task_type not in TASK_TYPE_PLUGIN_MAP:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}"
|
||||
)
|
||||
plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type])
|
||||
|
||||
return task_manager.get_tasks(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status=status_filter,
|
||||
plugin_ids=plugin_filters or None,
|
||||
completed_only=completed_only
|
||||
)
|
||||
async def list_tasks(
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status: Optional[TaskStatus] = None,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_ = Depends(has_permission("tasks", "READ"))
|
||||
):
|
||||
"""
|
||||
Retrieve a list of tasks with pagination and optional status filter.
|
||||
"""
|
||||
with belief_scope("list_tasks"):
|
||||
return task_manager.get_tasks(limit=limit, offset=offset, status=status)
|
||||
# [/DEF:list_tasks:Function]
|
||||
|
||||
@router.get("/{task_id}", response_model=Task)
|
||||
@@ -300,4 +276,4 @@ async def clear_tasks(
|
||||
task_manager.clear_tasks(status)
|
||||
return
|
||||
# [/DEF:clear_tasks:Function]
|
||||
# [/DEF:TasksRouter:Module]
|
||||
# [/DEF:TasksRouter:Module]
|
||||
@@ -21,7 +21,7 @@ import asyncio
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.utils.network import NetworkError
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets
|
||||
from .api import auth
|
||||
|
||||
# [DEF:App:Global]
|
||||
@@ -119,12 +119,10 @@ app.include_router(environments.router, tags=["Environments"])
|
||||
app.include_router(mappings.router, prefix="/api/mappings", tags=["Mappings"])
|
||||
app.include_router(migration.router)
|
||||
app.include_router(git.router, prefix="/api/git", tags=["Git"])
|
||||
app.include_router(llm.router, prefix="/api/llm", tags=["LLM"])
|
||||
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||
app.include_router(dashboards.router)
|
||||
app.include_router(datasets.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"])
|
||||
app.include_router(llm.router, prefix="/api/llm", tags=["LLM"])
|
||||
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||
app.include_router(dashboards.router)
|
||||
app.include_router(datasets.router)
|
||||
|
||||
|
||||
# [DEF:api.include_routers:Action]
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
# @INVARIANT: Uses bcrypt for hashing with standard work factor.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import bcrypt
|
||||
from passlib.context import CryptContext
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:pwd_context:Variable]
|
||||
# @PURPOSE: Passlib CryptContext for password management.
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
# [/DEF:pwd_context:Variable]
|
||||
|
||||
# [DEF:verify_password:Function]
|
||||
# @PURPOSE: Verifies a plain password against a hashed password.
|
||||
# @PRE: plain_password is a string, hashed_password is a bcrypt hash.
|
||||
@@ -20,15 +25,7 @@ import bcrypt
|
||||
# @PARAM: hashed_password (str) - The stored hash.
|
||||
# @RETURN: bool - Verification result.
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
if not hashed_password:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"),
|
||||
hashed_password.encode("utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
# [/DEF:verify_password:Function]
|
||||
|
||||
# [DEF:get_password_hash:Function]
|
||||
@@ -39,7 +36,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
# @PARAM: password (str) - The password to hash.
|
||||
# @RETURN: str - The generated hash.
|
||||
def get_password_hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
return pwd_context.hash(password)
|
||||
# [/DEF:get_password_hash:Function]
|
||||
|
||||
# [/DEF:backend.src.core.auth.security:Module]
|
||||
# [/DEF:backend.src.core.auth.security:Module]
|
||||
@@ -1,6 +1,5 @@
|
||||
# [DEF:ConfigManagerModule:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: config, manager, persistence, postgresql
|
||||
# @PURPOSE: Manages application configuration persisted in database with one-time migration from JSON.
|
||||
# @LAYER: Core
|
||||
@@ -27,11 +26,9 @@ from .logger import logger, configure_logger, belief_scope
|
||||
|
||||
|
||||
# [DEF:ConfigManager:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: A class to handle application configuration persistence and management.
|
||||
class ConfigManager:
|
||||
# [DEF:__init__:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Initializes the ConfigManager.
|
||||
# @PRE: isinstance(config_path, str) and len(config_path) > 0
|
||||
# @POST: self.config is an instance of AppConfig
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
# @RELATION: READS_FROM -> app_configurations (database)
|
||||
# @RELATION: USED_BY -> ConfigManager
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from ..models.storage import StorageConfig
|
||||
from ..services.llm_prompt_templates import DEFAULT_LLM_PROMPTS
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from ..models.storage import StorageConfig
|
||||
|
||||
# [DEF:Schedule:DataClass]
|
||||
# @PURPOSE: Represents a backup schedule configuration.
|
||||
@@ -45,18 +44,12 @@ class LoggingConfig(BaseModel):
|
||||
|
||||
# [DEF:GlobalSettings:DataClass]
|
||||
# @PURPOSE: Represents global application settings.
|
||||
class GlobalSettings(BaseModel):
|
||||
class GlobalSettings(BaseModel):
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
default_environment_id: Optional[str] = None
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
connections: List[dict] = []
|
||||
llm: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"providers": [],
|
||||
"default_provider": "",
|
||||
"prompts": dict(DEFAULT_LLM_PROMPTS),
|
||||
}
|
||||
)
|
||||
llm: dict = Field(default_factory=lambda: {"providers": [], "default_provider": ""})
|
||||
|
||||
# Task retention settings
|
||||
task_retention_days: int = 30
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# [DEF:backend.src.core.database:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: database, postgresql, sqlalchemy, session, persistence
|
||||
# @PURPOSE: Configures database connection and session management (PostgreSQL-first).
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.mapping
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.auth.config
|
||||
# @RELATION: USES -> backend.src.models.mapping
|
||||
# @RELATION: USES -> backend.src.core.auth.config
|
||||
#
|
||||
# @INVARIANT: A single engine instance is used for the entire application.
|
||||
|
||||
@@ -18,8 +17,6 @@ from ..models.mapping import Base
|
||||
from ..models import task as _task_models # noqa: F401
|
||||
from ..models import auth as _auth_models # noqa: F401
|
||||
from ..models import config as _config_models # noqa: F401
|
||||
from ..models import llm as _llm_models # noqa: F401
|
||||
from ..models import assistant as _assistant_models # noqa: F401
|
||||
from .logger import belief_scope
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
@@ -74,21 +71,18 @@ auth_engine = _build_engine(AUTH_DATABASE_URL)
|
||||
# [/DEF:auth_engine:Variable]
|
||||
|
||||
# [DEF:SessionLocal:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: A session factory for the main mappings database.
|
||||
# @PRE: engine is initialized.
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
# [/DEF:SessionLocal:Class]
|
||||
|
||||
# [DEF:TasksSessionLocal:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: A session factory for the tasks execution database.
|
||||
# @PRE: tasks_engine is initialized.
|
||||
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
|
||||
# [/DEF:TasksSessionLocal:Class]
|
||||
|
||||
# [DEF:AuthSessionLocal:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: A session factory for the authentication database.
|
||||
# @PRE: auth_engine is initialized.
|
||||
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union, cast
|
||||
@@ -121,252 +120,6 @@ class SupersetClient:
|
||||
return result
|
||||
# [/DEF:get_dashboards_summary:Function]
|
||||
|
||||
# [DEF:get_dashboard:Function]
|
||||
# @PURPOSE: Fetches a single dashboard by ID.
|
||||
# @PRE: Client is authenticated and dashboard_id exists.
|
||||
# @POST: Returns dashboard payload from Superset API.
|
||||
# @RETURN: Dict
|
||||
def get_dashboard(self, dashboard_id: int) -> Dict:
|
||||
with belief_scope("SupersetClient.get_dashboard", f"id={dashboard_id}"):
|
||||
response = self.network.request(method="GET", endpoint=f"/dashboard/{dashboard_id}")
|
||||
return cast(Dict, response)
|
||||
# [/DEF:get_dashboard:Function]
|
||||
|
||||
# [DEF:get_chart:Function]
|
||||
# @PURPOSE: Fetches a single chart by ID.
|
||||
# @PRE: Client is authenticated and chart_id exists.
|
||||
# @POST: Returns chart payload from Superset API.
|
||||
# @RETURN: Dict
|
||||
def get_chart(self, chart_id: int) -> Dict:
|
||||
with belief_scope("SupersetClient.get_chart", f"id={chart_id}"):
|
||||
response = self.network.request(method="GET", endpoint=f"/chart/{chart_id}")
|
||||
return cast(Dict, response)
|
||||
# [/DEF:get_chart:Function]
|
||||
|
||||
# [DEF:get_dashboard_detail:Function]
|
||||
# @PURPOSE: Fetches detailed dashboard information including related charts and datasets.
|
||||
# @PRE: Client is authenticated and dashboard_id exists.
|
||||
# @POST: Returns dashboard metadata with charts and datasets lists.
|
||||
# @RETURN: Dict
|
||||
def get_dashboard_detail(self, dashboard_id: int) -> Dict:
|
||||
with belief_scope("SupersetClient.get_dashboard_detail", f"id={dashboard_id}"):
|
||||
dashboard_response = self.get_dashboard(dashboard_id)
|
||||
dashboard_data = dashboard_response.get("result", dashboard_response)
|
||||
|
||||
charts: List[Dict] = []
|
||||
datasets: List[Dict] = []
|
||||
|
||||
def extract_dataset_id_from_form_data(form_data: Optional[Dict]) -> Optional[int]:
|
||||
if not isinstance(form_data, dict):
|
||||
return None
|
||||
datasource = form_data.get("datasource")
|
||||
if isinstance(datasource, str):
|
||||
matched = re.match(r"^(\d+)__", datasource)
|
||||
if matched:
|
||||
try:
|
||||
return int(matched.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(datasource, dict):
|
||||
ds_id = datasource.get("id")
|
||||
try:
|
||||
return int(ds_id) if ds_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
ds_id = form_data.get("datasource_id")
|
||||
try:
|
||||
return int(ds_id) if ds_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# Canonical endpoints from Superset OpenAPI:
|
||||
# /dashboard/{id_or_slug}/charts and /dashboard/{id_or_slug}/datasets.
|
||||
try:
|
||||
charts_response = self.network.request(
|
||||
method="GET",
|
||||
endpoint=f"/dashboard/{dashboard_id}/charts"
|
||||
)
|
||||
charts_payload = charts_response.get("result", []) if isinstance(charts_response, dict) else []
|
||||
for chart_obj in charts_payload:
|
||||
if not isinstance(chart_obj, dict):
|
||||
continue
|
||||
chart_id = chart_obj.get("id")
|
||||
if chart_id is None:
|
||||
continue
|
||||
form_data = chart_obj.get("form_data")
|
||||
if isinstance(form_data, str):
|
||||
try:
|
||||
form_data = json.loads(form_data)
|
||||
except Exception:
|
||||
form_data = {}
|
||||
dataset_id = extract_dataset_id_from_form_data(form_data) or chart_obj.get("datasource_id")
|
||||
charts.append({
|
||||
"id": int(chart_id),
|
||||
"title": chart_obj.get("slice_name") or chart_obj.get("name") or f"Chart {chart_id}",
|
||||
"viz_type": (form_data.get("viz_type") if isinstance(form_data, dict) else None),
|
||||
"dataset_id": int(dataset_id) if dataset_id is not None else None,
|
||||
"last_modified": chart_obj.get("changed_on"),
|
||||
"overview": chart_obj.get("description") or (form_data.get("viz_type") if isinstance(form_data, dict) else None) or "Chart",
|
||||
})
|
||||
except Exception as e:
|
||||
app_logger.warning("[get_dashboard_detail][Warning] Failed to fetch dashboard charts: %s", e)
|
||||
|
||||
try:
|
||||
datasets_response = self.network.request(
|
||||
method="GET",
|
||||
endpoint=f"/dashboard/{dashboard_id}/datasets"
|
||||
)
|
||||
datasets_payload = datasets_response.get("result", []) if isinstance(datasets_response, dict) else []
|
||||
for dataset_obj in datasets_payload:
|
||||
if not isinstance(dataset_obj, dict):
|
||||
continue
|
||||
dataset_id = dataset_obj.get("id")
|
||||
if dataset_id is None:
|
||||
continue
|
||||
db_payload = dataset_obj.get("database")
|
||||
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
|
||||
table_name = dataset_obj.get("table_name") or dataset_obj.get("datasource_name") or dataset_obj.get("name") or f"Dataset {dataset_id}"
|
||||
schema = dataset_obj.get("schema")
|
||||
fq_name = f"{schema}.{table_name}" if schema else table_name
|
||||
datasets.append({
|
||||
"id": int(dataset_id),
|
||||
"table_name": table_name,
|
||||
"schema": schema,
|
||||
"database": db_name or dataset_obj.get("database_name") or "Unknown",
|
||||
"last_modified": dataset_obj.get("changed_on"),
|
||||
"overview": fq_name,
|
||||
})
|
||||
except Exception as e:
|
||||
app_logger.warning("[get_dashboard_detail][Warning] Failed to fetch dashboard datasets: %s", e)
|
||||
|
||||
# Fallback: derive chart IDs from layout metadata if dashboard charts endpoint fails.
|
||||
if not charts:
|
||||
raw_position_json = dashboard_data.get("position_json")
|
||||
chart_ids_from_position = set()
|
||||
if isinstance(raw_position_json, str) and raw_position_json:
|
||||
try:
|
||||
parsed_position = json.loads(raw_position_json)
|
||||
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_position))
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(raw_position_json, dict):
|
||||
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_position_json))
|
||||
|
||||
raw_json_metadata = dashboard_data.get("json_metadata")
|
||||
if isinstance(raw_json_metadata, str) and raw_json_metadata:
|
||||
try:
|
||||
parsed_metadata = json.loads(raw_json_metadata)
|
||||
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_metadata))
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(raw_json_metadata, dict):
|
||||
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_json_metadata))
|
||||
|
||||
app_logger.info(
|
||||
"[get_dashboard_detail][State] Extracted %s fallback chart IDs from layout (dashboard_id=%s)",
|
||||
len(chart_ids_from_position),
|
||||
dashboard_id,
|
||||
)
|
||||
|
||||
for chart_id in sorted(chart_ids_from_position):
|
||||
try:
|
||||
chart_response = self.get_chart(int(chart_id))
|
||||
chart_data = chart_response.get("result", chart_response)
|
||||
charts.append({
|
||||
"id": int(chart_id),
|
||||
"title": chart_data.get("slice_name") or chart_data.get("name") or f"Chart {chart_id}",
|
||||
"viz_type": chart_data.get("viz_type"),
|
||||
"dataset_id": chart_data.get("datasource_id"),
|
||||
"last_modified": chart_data.get("changed_on"),
|
||||
"overview": chart_data.get("description") or chart_data.get("viz_type") or "Chart",
|
||||
})
|
||||
except Exception as e:
|
||||
app_logger.warning("[get_dashboard_detail][Warning] Failed to resolve fallback chart %s: %s", chart_id, e)
|
||||
|
||||
# Backfill datasets from chart datasource IDs.
|
||||
dataset_ids_from_charts = {
|
||||
c.get("dataset_id")
|
||||
for c in charts
|
||||
if c.get("dataset_id") is not None
|
||||
}
|
||||
known_dataset_ids = {d.get("id") for d in datasets}
|
||||
missing_dataset_ids = [ds_id for ds_id in dataset_ids_from_charts if ds_id not in known_dataset_ids]
|
||||
|
||||
for dataset_id in missing_dataset_ids:
|
||||
try:
|
||||
dataset_response = self.get_dataset(int(dataset_id))
|
||||
dataset_data = dataset_response.get("result", dataset_response)
|
||||
db_payload = dataset_data.get("database")
|
||||
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
|
||||
table_name = dataset_data.get("table_name") or f"Dataset {dataset_id}"
|
||||
schema = dataset_data.get("schema")
|
||||
fq_name = f"{schema}.{table_name}" if schema else table_name
|
||||
datasets.append({
|
||||
"id": int(dataset_id),
|
||||
"table_name": table_name,
|
||||
"schema": schema,
|
||||
"database": db_name or "Unknown",
|
||||
"last_modified": dataset_data.get("changed_on_utc") or dataset_data.get("changed_on"),
|
||||
"overview": fq_name,
|
||||
})
|
||||
except Exception as e:
|
||||
app_logger.warning("[get_dashboard_detail][Warning] Failed to resolve dataset %s: %s", dataset_id, e)
|
||||
|
||||
unique_charts = {}
|
||||
for chart in charts:
|
||||
unique_charts[chart["id"]] = chart
|
||||
|
||||
unique_datasets = {}
|
||||
for dataset in datasets:
|
||||
unique_datasets[dataset["id"]] = dataset
|
||||
|
||||
return {
|
||||
"id": dashboard_data.get("id", dashboard_id),
|
||||
"title": dashboard_data.get("dashboard_title") or dashboard_data.get("title") or f"Dashboard {dashboard_id}",
|
||||
"slug": dashboard_data.get("slug"),
|
||||
"url": dashboard_data.get("url"),
|
||||
"description": dashboard_data.get("description") or "",
|
||||
"last_modified": dashboard_data.get("changed_on_utc") or dashboard_data.get("changed_on"),
|
||||
"published": dashboard_data.get("published"),
|
||||
"charts": list(unique_charts.values()),
|
||||
"datasets": list(unique_datasets.values()),
|
||||
"chart_count": len(unique_charts),
|
||||
"dataset_count": len(unique_datasets),
|
||||
}
|
||||
# [/DEF:get_dashboard_detail:Function]
|
||||
|
||||
# [DEF:_extract_chart_ids_from_layout:Function]
|
||||
# @PURPOSE: Traverses dashboard layout metadata and extracts chart IDs from common keys.
|
||||
# @PRE: payload can be dict/list/scalar.
|
||||
# @POST: Returns a set of chart IDs found in nested structures.
|
||||
def _extract_chart_ids_from_layout(self, payload: Union[Dict, List, str, int, None]) -> set:
|
||||
with belief_scope("_extract_chart_ids_from_layout"):
|
||||
found = set()
|
||||
|
||||
def walk(node):
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key in ("chartId", "chart_id", "slice_id", "sliceId"):
|
||||
try:
|
||||
found.add(int(value))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if key == "id" and isinstance(value, str):
|
||||
match = re.match(r"^CHART-(\d+)$", value)
|
||||
if match:
|
||||
try:
|
||||
found.add(int(match.group(1)))
|
||||
except ValueError:
|
||||
pass
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
|
||||
walk(payload)
|
||||
return found
|
||||
# [/DEF:_extract_chart_ids_from_layout:Function]
|
||||
|
||||
# [DEF:export_dashboard:Function]
|
||||
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
|
||||
# @PARAM: dashboard_id (int) - ID дашборда для экспорта.
|
||||
@@ -493,15 +246,6 @@ class SupersetClient:
|
||||
# @RELATION: CALLS -> self.network.request (for related_objects)
|
||||
def get_dataset_detail(self, dataset_id: int) -> Dict:
|
||||
with belief_scope("SupersetClient.get_dataset_detail", f"id={dataset_id}"):
|
||||
def as_bool(value, default=False):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("1", "true", "yes", "y", "on")
|
||||
return bool(value)
|
||||
|
||||
# Get base dataset info
|
||||
response = self.get_dataset(dataset_id)
|
||||
|
||||
@@ -515,15 +259,12 @@ class SupersetClient:
|
||||
columns = dataset.get("columns", [])
|
||||
column_info = []
|
||||
for col in columns:
|
||||
col_id = col.get("id")
|
||||
if col_id is None:
|
||||
continue
|
||||
column_info.append({
|
||||
"id": int(col_id),
|
||||
"id": col.get("id"),
|
||||
"name": col.get("column_name"),
|
||||
"type": col.get("type"),
|
||||
"is_dttm": as_bool(col.get("is_dttm"), default=False),
|
||||
"is_active": as_bool(col.get("is_active"), default=True),
|
||||
"is_dttm": col.get("is_dttm", False),
|
||||
"is_active": col.get("is_active", True),
|
||||
"description": col.get("description", "")
|
||||
})
|
||||
|
||||
@@ -545,25 +286,11 @@ class SupersetClient:
|
||||
dashboards_data = []
|
||||
|
||||
for dash in dashboards_data:
|
||||
if isinstance(dash, dict):
|
||||
dash_id = dash.get("id")
|
||||
if dash_id is None:
|
||||
continue
|
||||
linked_dashboards.append({
|
||||
"id": int(dash_id),
|
||||
"title": dash.get("dashboard_title") or dash.get("title", f"Dashboard {dash_id}"),
|
||||
"slug": dash.get("slug")
|
||||
})
|
||||
else:
|
||||
try:
|
||||
dash_id = int(dash)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
linked_dashboards.append({
|
||||
"id": dash_id,
|
||||
"title": f"Dashboard {dash_id}",
|
||||
"slug": None
|
||||
})
|
||||
linked_dashboards.append({
|
||||
"id": dash.get("id"),
|
||||
"title": dash.get("dashboard_title") or dash.get("title", "Unknown"),
|
||||
"slug": dash.get("slug")
|
||||
})
|
||||
except Exception as e:
|
||||
app_logger.warning(f"[get_dataset_detail][Warning] Failed to fetch related dashboards: {e}")
|
||||
linked_dashboards = []
|
||||
@@ -575,18 +302,14 @@ class SupersetClient:
|
||||
"id": dataset.get("id"),
|
||||
"table_name": dataset.get("table_name"),
|
||||
"schema": dataset.get("schema"),
|
||||
"database": (
|
||||
dataset.get("database", {}).get("database_name", "Unknown")
|
||||
if isinstance(dataset.get("database"), dict)
|
||||
else dataset.get("database_name") or "Unknown"
|
||||
),
|
||||
"database": dataset.get("database", {}).get("database_name", "Unknown"),
|
||||
"description": dataset.get("description", ""),
|
||||
"columns": column_info,
|
||||
"column_count": len(column_info),
|
||||
"sql": sql,
|
||||
"linked_dashboards": linked_dashboards,
|
||||
"linked_dashboard_count": len(linked_dashboards),
|
||||
"is_sqllab_view": as_bool(dataset.get("is_sqllab_view"), default=False),
|
||||
"is_sqllab_view": dataset.get("is_sqllab_view", False),
|
||||
"created_on": dataset.get("created_on"),
|
||||
"changed_on": dataset.get("changed_on")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import asyncio
|
||||
import threading
|
||||
import inspect
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from .models import Task, TaskStatus, LogEntry, LogFilter, LogStats
|
||||
@@ -312,35 +312,13 @@ class TaskManager:
|
||||
# @PARAM: offset (int) - Number of tasks to skip.
|
||||
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
|
||||
# @RETURN: List[Task] - List of tasks matching criteria.
|
||||
def get_tasks(
|
||||
self,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status: Optional[TaskStatus] = None,
|
||||
plugin_ids: Optional[List[str]] = None,
|
||||
completed_only: bool = False
|
||||
) -> List[Task]:
|
||||
def get_tasks(self, limit: int = 10, offset: int = 0, status: Optional[TaskStatus] = None) -> List[Task]:
|
||||
with belief_scope("TaskManager.get_tasks"):
|
||||
tasks = list(self.tasks.values())
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.status == status]
|
||||
if plugin_ids:
|
||||
plugin_id_set = set(plugin_ids)
|
||||
tasks = [t for t in tasks if t.plugin_id in plugin_id_set]
|
||||
if completed_only:
|
||||
tasks = [t for t in tasks if t.status in [TaskStatus.SUCCESS, TaskStatus.FAILED]]
|
||||
# Sort by started_at descending with tolerant handling of mixed tz-aware/naive values.
|
||||
def sort_key(task: Task) -> float:
|
||||
started_at = task.started_at
|
||||
if started_at is None:
|
||||
return float("-inf")
|
||||
if not isinstance(started_at, datetime):
|
||||
return float("-inf")
|
||||
if started_at.tzinfo is None:
|
||||
return started_at.replace(tzinfo=timezone.utc).timestamp()
|
||||
return started_at.timestamp()
|
||||
|
||||
tasks.sort(key=sort_key, reverse=True)
|
||||
# Sort by start_time descending (most recent first)
|
||||
tasks.sort(key=lambda t: t.started_at or datetime.min, reverse=True)
|
||||
return tasks[offset:offset + limit]
|
||||
# [/DEF:get_tasks:Function]
|
||||
|
||||
@@ -590,4 +568,4 @@ class TaskManager:
|
||||
# [/DEF:clear_tasks:Function]
|
||||
|
||||
# [/DEF:TaskManager:Class]
|
||||
# [/DEF:TaskManagerModule:Module]
|
||||
# [/DEF:TaskManagerModule:Module]
|
||||
@@ -109,8 +109,7 @@ class Task(BaseModel):
|
||||
params: Dict[str, Any] = Field(default_factory=dict)
|
||||
input_required: bool = False
|
||||
input_request: Optional[Dict[str, Any]] = None
|
||||
# Result payload can be dict/list/scalar depending on plugin and legacy records.
|
||||
result: Optional[Any] = None
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the Task model and validates input_request for AWAITING_INPUT status.
|
||||
@@ -124,4 +123,4 @@ class Task(BaseModel):
|
||||
# [/DEF:__init__:Function]
|
||||
# [/DEF:Task:Class]
|
||||
|
||||
# [/DEF:TaskManagerModels:Module]
|
||||
# [/DEF:TaskManagerModels:Module]
|
||||
@@ -12,7 +12,6 @@ import json
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from ...models.task import TaskRecord, TaskLogRecord
|
||||
from ...models.mapping import Environment
|
||||
from ..database import TasksSessionLocal
|
||||
from .models import Task, TaskStatus, LogEntry, TaskLog, LogFilter, LogStats
|
||||
from ..logger import logger, belief_scope
|
||||
@@ -22,40 +21,6 @@ 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.
|
||||
class TaskPersistenceService:
|
||||
@staticmethod
|
||||
def _json_load_if_needed(value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped == "" or stripped.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(value):
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_environment_id(session: Session, env_id: Optional[str]) -> Optional[str]:
|
||||
if not env_id:
|
||||
return None
|
||||
exists = session.query(Environment.id).filter(Environment.id == env_id).first()
|
||||
return env_id if exists else None
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the persistence service.
|
||||
# @PRE: None.
|
||||
@@ -83,8 +48,7 @@ class TaskPersistenceService:
|
||||
|
||||
record.type = task.plugin_id
|
||||
record.status = task.status.value
|
||||
raw_env_id = task.params.get("environment_id") or task.params.get("source_env_id")
|
||||
record.environment_id = self._resolve_environment_id(session, raw_env_id)
|
||||
record.environment_id = task.params.get("environment_id") or task.params.get("source_env_id")
|
||||
record.started_at = task.started_at
|
||||
record.finished_at = task.finished_at
|
||||
|
||||
@@ -159,28 +123,21 @@ class TaskPersistenceService:
|
||||
for record in records:
|
||||
try:
|
||||
logs = []
|
||||
logs_payload = self._json_load_if_needed(record.logs)
|
||||
if isinstance(logs_payload, list):
|
||||
for log_data in logs_payload:
|
||||
if not isinstance(log_data, dict):
|
||||
continue
|
||||
log_data = dict(log_data)
|
||||
log_data['timestamp'] = self._parse_datetime(log_data.get('timestamp')) or datetime.utcnow()
|
||||
if record.logs:
|
||||
for log_data in record.logs:
|
||||
# Handle timestamp conversion if it's a string
|
||||
if isinstance(log_data.get('timestamp'), str):
|
||||
log_data['timestamp'] = datetime.fromisoformat(log_data['timestamp'])
|
||||
logs.append(LogEntry(**log_data))
|
||||
|
||||
started_at = self._parse_datetime(record.started_at)
|
||||
finished_at = self._parse_datetime(record.finished_at)
|
||||
params = self._json_load_if_needed(record.params)
|
||||
result = self._json_load_if_needed(record.result)
|
||||
|
||||
task = Task(
|
||||
id=record.id,
|
||||
plugin_id=record.type,
|
||||
status=TaskStatus(record.status),
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
params=params if isinstance(params, dict) else {},
|
||||
result=result,
|
||||
started_at=record.started_at,
|
||||
finished_at=record.finished_at,
|
||||
params=record.params or {},
|
||||
result=record.result,
|
||||
logs=logs
|
||||
)
|
||||
loaded_tasks.append(task)
|
||||
@@ -424,4 +381,4 @@ class TaskLogPersistenceService:
|
||||
# [/DEF:delete_logs_for_tasks:Function]
|
||||
|
||||
# [/DEF:TaskLogPersistenceService:Class]
|
||||
# [/DEF:TaskPersistenceModule:Module]
|
||||
# [/DEF:TaskPersistenceModule:Module]
|
||||
@@ -1,74 +0,0 @@
|
||||
# [DEF:backend.src.models.assistant:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: assistant, audit, confirmation, chat
|
||||
# @PURPOSE: SQLAlchemy models for assistant audit trail and confirmation tokens.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.mapping
|
||||
# @INVARIANT: Assistant records preserve immutable ids and creation timestamps.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, JSON, Text
|
||||
|
||||
from .mapping import Base
|
||||
|
||||
|
||||
# [DEF:AssistantAuditRecord:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Store audit decisions and outcomes produced by assistant command handling.
|
||||
# @PRE: user_id must identify the actor for every record.
|
||||
# @POST: Audit payload remains available for compliance and debugging.
|
||||
class AssistantAuditRecord(Base):
|
||||
__tablename__ = "assistant_audit"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
user_id = Column(String, index=True, nullable=False)
|
||||
conversation_id = Column(String, index=True, nullable=True)
|
||||
decision = Column(String, nullable=True)
|
||||
task_id = Column(String, nullable=True)
|
||||
message = Column(Text, nullable=True)
|
||||
payload = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
# [/DEF:AssistantAuditRecord:Class]
|
||||
|
||||
|
||||
# [DEF:AssistantMessageRecord:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Persist chat history entries for assistant conversations.
|
||||
# @PRE: user_id, conversation_id, role and text must be present.
|
||||
# @POST: Message row can be queried in chronological order.
|
||||
class AssistantMessageRecord(Base):
|
||||
__tablename__ = "assistant_messages"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
user_id = Column(String, index=True, nullable=False)
|
||||
conversation_id = Column(String, index=True, nullable=False)
|
||||
role = Column(String, nullable=False) # user | assistant
|
||||
text = Column(Text, nullable=False)
|
||||
state = Column(String, nullable=True)
|
||||
task_id = Column(String, nullable=True)
|
||||
confirmation_id = Column(String, nullable=True)
|
||||
payload = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
# [/DEF:AssistantMessageRecord:Class]
|
||||
|
||||
|
||||
# [DEF:AssistantConfirmationRecord:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Persist risky operation confirmation tokens with lifecycle state.
|
||||
# @PRE: intent/dispatch and expiry timestamp must be provided.
|
||||
# @POST: State transitions can be tracked and audited.
|
||||
class AssistantConfirmationRecord(Base):
|
||||
__tablename__ = "assistant_confirmations"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
user_id = Column(String, index=True, nullable=False)
|
||||
conversation_id = Column(String, index=True, nullable=False)
|
||||
state = Column(String, index=True, nullable=False, default="pending")
|
||||
intent = Column(JSON, nullable=False)
|
||||
dispatch = Column(JSON, nullable=False)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
consumed_at = Column(DateTime, nullable=True)
|
||||
# [/DEF:AssistantConfirmationRecord:Class]
|
||||
# [/DEF:backend.src.models.assistant:Module]
|
||||
@@ -1,128 +0,0 @@
|
||||
# [DEF:backend.src.models.report:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, models, pydantic, normalization, pagination
|
||||
# @PURPOSE: Canonical report schemas for unified task reporting across heterogeneous task types.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models
|
||||
# @INVARIANT: Canonical report fields are always present for every report item.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:TaskType:Class]
|
||||
# @PURPOSE: Supported normalized task report types.
|
||||
class TaskType(str, Enum):
|
||||
LLM_VERIFICATION = "llm_verification"
|
||||
BACKUP = "backup"
|
||||
MIGRATION = "migration"
|
||||
DOCUMENTATION = "documentation"
|
||||
UNKNOWN = "unknown"
|
||||
# [/DEF:TaskType:Class]
|
||||
|
||||
|
||||
# [DEF:ReportStatus:Class]
|
||||
# @PURPOSE: Supported normalized report status values.
|
||||
class ReportStatus(str, Enum):
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
IN_PROGRESS = "in_progress"
|
||||
PARTIAL = "partial"
|
||||
# [/DEF:ReportStatus:Class]
|
||||
|
||||
|
||||
# [DEF:ErrorContext:Class]
|
||||
# @PURPOSE: Error and recovery context for failed/partial reports.
|
||||
class ErrorContext(BaseModel):
|
||||
code: Optional[str] = None
|
||||
message: str
|
||||
next_actions: List[str] = Field(default_factory=list)
|
||||
# [/DEF:ErrorContext:Class]
|
||||
|
||||
|
||||
# [DEF:TaskReport:Class]
|
||||
# @PURPOSE: Canonical normalized report envelope for one task execution.
|
||||
class TaskReport(BaseModel):
|
||||
report_id: str
|
||||
task_id: str
|
||||
task_type: TaskType
|
||||
status: ReportStatus
|
||||
started_at: Optional[datetime] = None
|
||||
updated_at: datetime
|
||||
summary: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
error_context: Optional[ErrorContext] = None
|
||||
source_ref: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_validator("report_id", "task_id", "summary")
|
||||
@classmethod
|
||||
def _non_empty_str(cls, value: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("Value must be a non-empty string")
|
||||
return value.strip()
|
||||
# [/DEF:TaskReport:Class]
|
||||
|
||||
|
||||
# [DEF:ReportQuery:Class]
|
||||
# @PURPOSE: Query object for server-side report filtering, sorting, and pagination.
|
||||
class ReportQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=20, ge=1, le=100)
|
||||
task_types: List[TaskType] = Field(default_factory=list)
|
||||
statuses: List[ReportStatus] = Field(default_factory=list)
|
||||
time_from: Optional[datetime] = None
|
||||
time_to: Optional[datetime] = None
|
||||
search: Optional[str] = Field(default=None, max_length=200)
|
||||
sort_by: str = Field(default="updated_at")
|
||||
sort_order: str = Field(default="desc")
|
||||
|
||||
@field_validator("sort_by")
|
||||
@classmethod
|
||||
def _validate_sort_by(cls, value: str) -> str:
|
||||
allowed = {"updated_at", "status", "task_type"}
|
||||
if value not in allowed:
|
||||
raise ValueError(f"sort_by must be one of: {', '.join(sorted(allowed))}")
|
||||
return value
|
||||
|
||||
@field_validator("sort_order")
|
||||
@classmethod
|
||||
def _validate_sort_order(cls, value: str) -> str:
|
||||
if value not in {"asc", "desc"}:
|
||||
raise ValueError("sort_order must be 'asc' or 'desc'")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_time_range(self):
|
||||
if self.time_from and self.time_to and self.time_from > self.time_to:
|
||||
raise ValueError("time_from must be less than or equal to time_to")
|
||||
return self
|
||||
# [/DEF:ReportQuery:Class]
|
||||
|
||||
|
||||
# [DEF:ReportCollection:Class]
|
||||
# @PURPOSE: Paginated collection of normalized task reports.
|
||||
class ReportCollection(BaseModel):
|
||||
items: List[TaskReport]
|
||||
total: int = Field(ge=0)
|
||||
page: int = Field(ge=1)
|
||||
page_size: int = Field(ge=1)
|
||||
has_next: bool
|
||||
applied_filters: ReportQuery
|
||||
# [/DEF:ReportCollection:Class]
|
||||
|
||||
|
||||
# [DEF:ReportDetailView:Class]
|
||||
# @PURPOSE: Detailed report representation including diagnostics and recovery actions.
|
||||
class ReportDetailView(BaseModel):
|
||||
report: TaskReport
|
||||
timeline: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
diagnostics: Optional[Dict[str, Any]] = None
|
||||
next_actions: List[str] = Field(default_factory=list)
|
||||
# [/DEF:ReportDetailView:Class]
|
||||
|
||||
# [/DEF:backend.src.models.report:Module]
|
||||
@@ -154,10 +154,10 @@ class BackupPlugin(PluginBase):
|
||||
|
||||
log.info(f"Starting backup for environment: {env}")
|
||||
|
||||
try:
|
||||
config_manager = get_config_manager()
|
||||
if not config_manager.has_environments():
|
||||
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
|
||||
try:
|
||||
config_manager = get_config_manager()
|
||||
if not config_manager.has_environments():
|
||||
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
|
||||
|
||||
env_config = config_manager.get_environment(env)
|
||||
if not env_config:
|
||||
@@ -180,27 +180,16 @@ class BackupPlugin(PluginBase):
|
||||
superset_log.info("No dashboard filter applied - backing up all dashboards")
|
||||
dashboard_meta = all_dashboard_meta
|
||||
|
||||
if dashboard_count == 0:
|
||||
log.info("No dashboards to back up")
|
||||
return {
|
||||
"status": "NO_DASHBOARDS",
|
||||
"environment": env,
|
||||
"backup_root": str(backup_path / env.upper()),
|
||||
"total_dashboards": 0,
|
||||
"backed_up_dashboards": 0,
|
||||
"failed_dashboards": 0,
|
||||
"dashboards": [],
|
||||
"failures": []
|
||||
}
|
||||
|
||||
total = len(dashboard_meta)
|
||||
backed_up_dashboards = []
|
||||
failed_dashboards = []
|
||||
for idx, db in enumerate(dashboard_meta, 1):
|
||||
dashboard_id = db.get('id')
|
||||
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
|
||||
if not dashboard_id:
|
||||
continue
|
||||
if dashboard_count == 0:
|
||||
log.info("No dashboards to back up")
|
||||
return
|
||||
|
||||
total = len(dashboard_meta)
|
||||
for idx, db in enumerate(dashboard_meta, 1):
|
||||
dashboard_id = db.get('id')
|
||||
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
|
||||
if not dashboard_id:
|
||||
continue
|
||||
|
||||
# Report progress
|
||||
progress_pct = (idx / total) * 100
|
||||
@@ -221,41 +210,21 @@ class BackupPlugin(PluginBase):
|
||||
unpack=False
|
||||
)
|
||||
|
||||
archive_exports(str(dashboard_dir), policy=RetentionPolicy())
|
||||
storage_log.debug(f"Archived dashboard: {dashboard_title}")
|
||||
backed_up_dashboards.append({
|
||||
"id": dashboard_id,
|
||||
"title": dashboard_title,
|
||||
"path": str(dashboard_dir)
|
||||
})
|
||||
|
||||
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
||||
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
|
||||
failed_dashboards.append({
|
||||
"id": dashboard_id,
|
||||
"title": dashboard_title,
|
||||
"error": str(db_error)
|
||||
})
|
||||
continue
|
||||
|
||||
consolidate_archive_folders(backup_path / env.upper())
|
||||
remove_empty_directories(str(backup_path / env.upper()))
|
||||
|
||||
log.info(f"Backup completed successfully for {env}")
|
||||
return {
|
||||
"status": "SUCCESS" if not failed_dashboards else "PARTIAL_SUCCESS",
|
||||
"environment": env,
|
||||
"backup_root": str(backup_path / env.upper()),
|
||||
"total_dashboards": total,
|
||||
"backed_up_dashboards": len(backed_up_dashboards),
|
||||
"failed_dashboards": len(failed_dashboards),
|
||||
"dashboards": backed_up_dashboards,
|
||||
"failures": failed_dashboards
|
||||
}
|
||||
|
||||
except (RequestException, IOError, KeyError) as e:
|
||||
log.error(f"Fatal error during backup for {env}: {e}")
|
||||
raise e
|
||||
archive_exports(str(dashboard_dir), policy=RetentionPolicy())
|
||||
storage_log.debug(f"Archived dashboard: {dashboard_title}")
|
||||
|
||||
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
||||
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
|
||||
continue
|
||||
|
||||
consolidate_archive_folders(backup_path / env.upper())
|
||||
remove_empty_directories(str(backup_path / env.upper()))
|
||||
|
||||
log.info(f"Backup completed successfully for {env}")
|
||||
|
||||
except (RequestException, IOError, KeyError) as e:
|
||||
log.error(f"Fatal error during backup for {env}: {e}")
|
||||
raise e
|
||||
# [/DEF:execute:Function]
|
||||
# [/DEF:BackupPlugin:Class]
|
||||
# [/DEF:BackupPlugin:Module]
|
||||
# [/DEF:BackupPlugin:Module]
|
||||
@@ -9,7 +9,6 @@ from typing import List
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
from ..llm_analysis.service import LLMClient
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt
|
||||
|
||||
# [DEF:GitLLMExtension:Class]
|
||||
# @PURPOSE: Provides LLM capabilities to the Git plugin.
|
||||
@@ -27,18 +26,21 @@ class GitLLMExtension:
|
||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||
reraise=True
|
||||
)
|
||||
async def suggest_commit_message(
|
||||
self,
|
||||
diff: str,
|
||||
history: List[str],
|
||||
prompt_template: str = DEFAULT_LLM_PROMPTS["git_commit_prompt"],
|
||||
) -> str:
|
||||
async def suggest_commit_message(self, diff: str, history: List[str]) -> str:
|
||||
with belief_scope("suggest_commit_message"):
|
||||
history_text = "\n".join(history)
|
||||
prompt = render_prompt(
|
||||
prompt_template,
|
||||
{"history": history_text, "diff": diff},
|
||||
)
|
||||
prompt = f"""
|
||||
Generate a concise and professional git commit message based on the following diff and recent history.
|
||||
Use Conventional Commits format (e.g., feat: ..., fix: ..., docs: ...).
|
||||
|
||||
Recent History:
|
||||
{history_text}
|
||||
|
||||
Diff:
|
||||
{diff}
|
||||
|
||||
Commit Message:
|
||||
"""
|
||||
|
||||
logger.debug(f"[suggest_commit_message] Calling LLM with model: {self.client.default_model}")
|
||||
response = await self.client.client.chat.completions.create(
|
||||
@@ -61,4 +63,4 @@ class GitLLMExtension:
|
||||
# [/DEF:suggest_commit_message:Function]
|
||||
# [/DEF:GitLLMExtension:Class]
|
||||
|
||||
# [/DEF:backend/src/plugins/git/llm_extension:Module]
|
||||
# [/DEF:backend/src/plugins/git/llm_extension:Module]
|
||||
@@ -23,11 +23,6 @@ from .service import ScreenshotService, LLMClient
|
||||
from .models import LLMProviderType, ValidationStatus, ValidationResult, DetectedIssue
|
||||
from ...models.llm import ValidationRecord
|
||||
from ...core.task_manager.context import TaskContext
|
||||
from ...services.llm_prompt_templates import (
|
||||
DEFAULT_LLM_PROMPTS,
|
||||
normalize_llm_settings,
|
||||
render_prompt,
|
||||
)
|
||||
|
||||
# [DEF:DashboardValidationPlugin:Class]
|
||||
# @PURPOSE: Plugin for automated dashboard health analysis using LLMs.
|
||||
@@ -79,8 +74,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
|
||||
log.info(f"Executing {self.name} with params: {params}")
|
||||
|
||||
dashboard_id_raw = params.get("dashboard_id")
|
||||
dashboard_id = str(dashboard_id_raw) if dashboard_id_raw is not None else None
|
||||
dashboard_id = params.get("dashboard_id")
|
||||
env_id = params.get("environment_id")
|
||||
provider_id = params.get("provider_id")
|
||||
|
||||
@@ -186,16 +180,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
)
|
||||
|
||||
llm_log.info(f"Analyzing dashboard {dashboard_id} with LLM")
|
||||
llm_settings = normalize_llm_settings(config_mgr.get_config().settings.llm)
|
||||
dashboard_prompt = llm_settings["prompts"].get(
|
||||
"dashboard_validation_prompt",
|
||||
DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"],
|
||||
)
|
||||
analysis = await llm_client.analyze_dashboard(
|
||||
screenshot_path,
|
||||
logs,
|
||||
prompt_template=dashboard_prompt,
|
||||
)
|
||||
analysis = await llm_client.analyze_dashboard(screenshot_path, logs)
|
||||
|
||||
# Log analysis summary to task logs for better visibility
|
||||
llm_log.info(f"[ANALYSIS_SUMMARY] Status: {analysis['status']}")
|
||||
@@ -355,18 +340,22 @@ class DocumentationPlugin(PluginBase):
|
||||
default_model=db_provider.default_model
|
||||
)
|
||||
|
||||
llm_settings = normalize_llm_settings(config_mgr.get_config().settings.llm)
|
||||
documentation_prompt = llm_settings["prompts"].get(
|
||||
"documentation_prompt",
|
||||
DEFAULT_LLM_PROMPTS["documentation_prompt"],
|
||||
)
|
||||
prompt = render_prompt(
|
||||
documentation_prompt,
|
||||
{
|
||||
"dataset_name": dataset.get("table_name") or "",
|
||||
"columns_json": json.dumps(columns_data, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
prompt = f"""
|
||||
Generate professional documentation for the following dataset and its columns.
|
||||
Dataset: {dataset.get('table_name')}
|
||||
Columns: {columns_data}
|
||||
|
||||
Provide the documentation in JSON format:
|
||||
{{
|
||||
"dataset_description": "General description of the dataset",
|
||||
"column_descriptions": [
|
||||
{{
|
||||
"name": "column_name",
|
||||
"description": "Generated description"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
# Using a generic chat completion for text-only US2
|
||||
llm_log.info(f"Generating documentation for dataset {dataset_id}")
|
||||
|
||||
@@ -20,7 +20,6 @@ from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_excep
|
||||
from .models import LLMProviderType
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...core.config_models import Environment
|
||||
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt
|
||||
|
||||
# [DEF:ScreenshotService:Class]
|
||||
# @PURPOSE: Handles capturing screenshots of Superset dashboards.
|
||||
@@ -549,12 +548,7 @@ class LLMClient:
|
||||
# @PRE: screenshot_path exists, logs is a list of strings.
|
||||
# @POST: Returns a structured analysis dictionary (status, summary, issues).
|
||||
# @SIDE_EFFECT: Reads screenshot file and calls external LLM API.
|
||||
async def analyze_dashboard(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
logs: List[str],
|
||||
prompt_template: str = DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"],
|
||||
) -> Dict[str, Any]:
|
||||
async def analyze_dashboard(self, screenshot_path: str, logs: List[str]) -> Dict[str, Any]:
|
||||
with belief_scope("analyze_dashboard"):
|
||||
# Optimize image to reduce token count (US1 / T023)
|
||||
# Gemini/Gemma models have limits on input tokens, and large images contribute significantly.
|
||||
@@ -588,7 +582,25 @@ class LLMClient:
|
||||
base_64_image = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
|
||||
log_text = "\n".join(logs)
|
||||
prompt = render_prompt(prompt_template, {"logs": log_text})
|
||||
prompt = f"""
|
||||
Analyze the attached dashboard screenshot and the following execution logs for health and visual issues.
|
||||
|
||||
Logs:
|
||||
{log_text}
|
||||
|
||||
Provide the analysis in JSON format with the following structure:
|
||||
{{
|
||||
"status": "PASS" | "WARN" | "FAIL",
|
||||
"summary": "Short summary of findings",
|
||||
"issues": [
|
||||
{{
|
||||
"severity": "WARN" | "FAIL",
|
||||
"message": "Description of the issue",
|
||||
"location": "Optional location info (e.g. chart name)"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
messages = [
|
||||
{
|
||||
|
||||
@@ -165,11 +165,11 @@ class MigrationPlugin(PluginBase):
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
migration_log = log.with_source("migration") if context else log
|
||||
|
||||
log.info("Starting migration task.")
|
||||
log.debug(f"Params: {params}")
|
||||
|
||||
try:
|
||||
with belief_scope("execute"):
|
||||
log.info("Starting migration task.")
|
||||
log.debug(f"Params: {params}")
|
||||
|
||||
try:
|
||||
with belief_scope("execute"):
|
||||
config_manager = get_config_manager()
|
||||
environments = config_manager.get_environments()
|
||||
|
||||
@@ -192,20 +192,11 @@ class MigrationPlugin(PluginBase):
|
||||
|
||||
from_env_name = src_env.name
|
||||
to_env_name = tgt_env.name
|
||||
|
||||
log.info(f"Resolved environments: {from_env_name} -> {to_env_name}")
|
||||
migration_result = {
|
||||
"status": "SUCCESS",
|
||||
"source_environment": from_env_name,
|
||||
"target_environment": to_env_name,
|
||||
"selected_dashboards": 0,
|
||||
"migrated_dashboards": [],
|
||||
"failed_dashboards": [],
|
||||
"mapping_count": 0
|
||||
}
|
||||
|
||||
from_c = SupersetClient(src_env)
|
||||
to_c = SupersetClient(tgt_env)
|
||||
|
||||
log.info(f"Resolved environments: {from_env_name} -> {to_env_name}")
|
||||
|
||||
from_c = SupersetClient(src_env)
|
||||
to_c = SupersetClient(tgt_env)
|
||||
|
||||
if not from_c or not to_c:
|
||||
raise ValueError(f"Clients not initialized for environments: {from_env_name}, {to_env_name}")
|
||||
@@ -213,24 +204,20 @@ class MigrationPlugin(PluginBase):
|
||||
_, all_dashboards = from_c.get_dashboards()
|
||||
|
||||
dashboards_to_migrate = []
|
||||
if selected_ids:
|
||||
dashboards_to_migrate = [d for d in all_dashboards if d["id"] in selected_ids]
|
||||
elif dashboard_regex:
|
||||
regex_str = str(dashboard_regex)
|
||||
dashboards_to_migrate = [
|
||||
if selected_ids:
|
||||
dashboards_to_migrate = [d for d in all_dashboards if d["id"] in selected_ids]
|
||||
elif dashboard_regex:
|
||||
regex_str = str(dashboard_regex)
|
||||
dashboards_to_migrate = [
|
||||
d for d in all_dashboards if re.search(regex_str, d["dashboard_title"], re.IGNORECASE)
|
||||
]
|
||||
else:
|
||||
log.warning("No selection criteria provided (selected_ids or dashboard_regex).")
|
||||
migration_result["status"] = "NO_SELECTION"
|
||||
return migration_result
|
||||
|
||||
if not dashboards_to_migrate:
|
||||
log.warning("No dashboards found matching criteria.")
|
||||
migration_result["status"] = "NO_MATCHES"
|
||||
return migration_result
|
||||
|
||||
migration_result["selected_dashboards"] = len(dashboards_to_migrate)
|
||||
else:
|
||||
log.warning("No selection criteria provided (selected_ids or dashboard_regex).")
|
||||
return
|
||||
|
||||
if not dashboards_to_migrate:
|
||||
log.warning("No dashboards found matching criteria.")
|
||||
return
|
||||
|
||||
# Get mappings from params
|
||||
db_mapping = params.get("db_mappings", {})
|
||||
@@ -251,18 +238,17 @@ class MigrationPlugin(PluginBase):
|
||||
DatabaseMapping.target_env_id == tgt_env_db.id
|
||||
).all()
|
||||
# Provided mappings override stored ones
|
||||
stored_map_dict = {m.source_db_uuid: m.target_db_uuid for m in stored_mappings}
|
||||
stored_map_dict.update(db_mapping)
|
||||
db_mapping = stored_map_dict
|
||||
log.info(f"Loaded {len(stored_mappings)} database mappings from database.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
migration_result["mapping_count"] = len(db_mapping)
|
||||
engine = MigrationEngine()
|
||||
|
||||
for dash in dashboards_to_migrate:
|
||||
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"]
|
||||
stored_map_dict = {m.source_db_uuid: m.target_db_uuid for m in stored_mappings}
|
||||
stored_map_dict.update(db_mapping)
|
||||
db_mapping = stored_map_dict
|
||||
log.info(f"Loaded {len(stored_mappings)} database mappings from database.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
engine = MigrationEngine()
|
||||
|
||||
for dash in dashboards_to_migrate:
|
||||
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"]
|
||||
|
||||
try:
|
||||
exported_content, _ = from_c.export_dashboard(dash_id)
|
||||
@@ -293,22 +279,13 @@ class MigrationPlugin(PluginBase):
|
||||
db.close()
|
||||
success = engine.transform_zip(str(tmp_zip_path), str(tmp_new_zip), db_mapping, strip_databases=False)
|
||||
|
||||
if success:
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
|
||||
migration_result["migrated_dashboards"].append({
|
||||
"id": dash_id,
|
||||
"title": title
|
||||
})
|
||||
else:
|
||||
migration_log.error(f"Failed to transform ZIP for dashboard {title}")
|
||||
migration_result["failed_dashboards"].append({
|
||||
"id": dash_id,
|
||||
"title": title,
|
||||
"error": "Failed to transform ZIP"
|
||||
})
|
||||
|
||||
superset_log.info(f"Dashboard {title} imported.")
|
||||
except Exception as exc:
|
||||
if success:
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
|
||||
else:
|
||||
migration_log.error(f"Failed to transform ZIP for dashboard {title}")
|
||||
|
||||
superset_log.info(f"Dashboard {title} imported.")
|
||||
except Exception as exc:
|
||||
# Check for password error
|
||||
error_msg = str(exc)
|
||||
# The error message from Superset is often a JSON string inside a string.
|
||||
@@ -347,34 +324,22 @@ class MigrationPlugin(PluginBase):
|
||||
passwords = task.params.get("passwords", {})
|
||||
|
||||
# Retry import with password
|
||||
if passwords:
|
||||
app_logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.")
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords)
|
||||
app_logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.")
|
||||
migration_result["migrated_dashboards"].append({
|
||||
"id": dash_id,
|
||||
"title": title
|
||||
})
|
||||
# Clear passwords from params after use for security
|
||||
if "passwords" in task.params:
|
||||
del task.params["passwords"]
|
||||
continue
|
||||
|
||||
app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
|
||||
migration_result["failed_dashboards"].append({
|
||||
"id": dash_id,
|
||||
"title": title,
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
app_logger.info("[MigrationPlugin][Exit] Migration finished.")
|
||||
if migration_result["failed_dashboards"]:
|
||||
migration_result["status"] = "PARTIAL_SUCCESS"
|
||||
return migration_result
|
||||
except Exception as e:
|
||||
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
|
||||
raise e
|
||||
if passwords:
|
||||
app_logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.")
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords)
|
||||
app_logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.")
|
||||
# Clear passwords from params after use for security
|
||||
if "passwords" in task.params:
|
||||
del task.params["passwords"]
|
||||
continue
|
||||
|
||||
app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
|
||||
|
||||
app_logger.info("[MigrationPlugin][Exit] Migration finished.")
|
||||
except Exception as e:
|
||||
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
|
||||
raise e
|
||||
# [/DEF:MigrationPlugin.execute:Action]
|
||||
# [/DEF:execute:Function]
|
||||
# [/DEF:MigrationPlugin:Class]
|
||||
# [/DEF:MigrationPlugin:Module]
|
||||
# [/DEF:MigrationPlugin:Module]
|
||||
@@ -1,6 +1,5 @@
|
||||
# [DEF:backend.src.scripts.migrate_sqlite_to_postgres:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: migration, sqlite, postgresql, config, task_logs, task_records
|
||||
# @PURPOSE: Migrates legacy config and task history from SQLite/file storage to PostgreSQL.
|
||||
# @LAYER: Scripts
|
||||
@@ -36,10 +35,7 @@ DEFAULT_TARGET_URL = os.getenv(
|
||||
|
||||
|
||||
# [DEF:_json_load_if_needed:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Parses JSON-like values from SQLite TEXT/JSON columns to Python objects.
|
||||
# @PRE: value is scalar JSON/text/list/dict or None.
|
||||
# @POST: Returns normalized Python object or original scalar value.
|
||||
def _json_load_if_needed(value: Any) -> Any:
|
||||
with belief_scope("_json_load_if_needed"):
|
||||
if value is None:
|
||||
@@ -56,7 +52,6 @@ def _json_load_if_needed(value: Any) -> Any:
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
return value
|
||||
# [/DEF:_json_load_if_needed:Function]
|
||||
|
||||
|
||||
# [DEF:_find_legacy_config_path:Function]
|
||||
@@ -75,7 +70,6 @@ def _find_legacy_config_path(explicit_path: Optional[str]) -> Optional[Path]:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
# [/DEF:_find_legacy_config_path:Function]
|
||||
|
||||
|
||||
# [DEF:_connect_sqlite:Function]
|
||||
@@ -85,7 +79,6 @@ def _connect_sqlite(path: Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
# [/DEF:_connect_sqlite:Function]
|
||||
|
||||
|
||||
# [DEF:_ensure_target_schema:Function]
|
||||
@@ -150,7 +143,6 @@ def _ensure_target_schema(engine) -> None:
|
||||
with engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
conn.execute(text(stmt))
|
||||
# [/DEF:_ensure_target_schema:Function]
|
||||
|
||||
|
||||
# [DEF:_migrate_config:Function]
|
||||
@@ -176,7 +168,6 @@ def _migrate_config(engine, legacy_config_path: Optional[Path]) -> int:
|
||||
)
|
||||
logger.info("[_migrate_config][Coherence:OK] Config migrated from %s", legacy_config_path)
|
||||
return 1
|
||||
# [/DEF:_migrate_config:Function]
|
||||
|
||||
|
||||
# [DEF:_migrate_tasks_and_logs:Function]
|
||||
@@ -292,7 +283,6 @@ def _migrate_tasks_and_logs(engine, sqlite_conn: sqlite3.Connection) -> Dict[str
|
||||
stats["task_logs_total"],
|
||||
)
|
||||
return stats
|
||||
# [/DEF:_migrate_tasks_and_logs:Function]
|
||||
|
||||
|
||||
# [DEF:run_migration:Function]
|
||||
@@ -313,7 +303,6 @@ def run_migration(sqlite_path: Path, target_url: str, legacy_config_path: Option
|
||||
return stats
|
||||
finally:
|
||||
sqlite_conn.close()
|
||||
# [/DEF:run_migration:Function]
|
||||
|
||||
|
||||
# [DEF:main:Function]
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# [DEF:backend.src.services.__tests__.test_llm_prompt_templates:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, llm, prompts, templates, settings
|
||||
# @PURPOSE: Validate normalization and rendering behavior for configurable LLM prompt templates.
|
||||
# @LAYER: Domain Tests
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.llm_prompt_templates
|
||||
# @INVARIANT: All required prompt keys remain available after normalization.
|
||||
|
||||
from src.services.llm_prompt_templates import (
|
||||
DEFAULT_LLM_PROMPTS,
|
||||
normalize_llm_settings,
|
||||
render_prompt,
|
||||
)
|
||||
|
||||
|
||||
# [DEF:test_normalize_llm_settings_adds_default_prompts:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Ensure legacy/partial llm settings are expanded with all prompt defaults.
|
||||
# @PRE: Input llm settings do not contain complete prompts object.
|
||||
# @POST: Returned structure includes required prompt templates with fallback defaults.
|
||||
def test_normalize_llm_settings_adds_default_prompts():
|
||||
normalized = normalize_llm_settings({"default_provider": "x"})
|
||||
|
||||
assert "prompts" in normalized
|
||||
assert normalized["default_provider"] == "x"
|
||||
for key in DEFAULT_LLM_PROMPTS:
|
||||
assert key in normalized["prompts"]
|
||||
assert isinstance(normalized["prompts"][key], str)
|
||||
# [/DEF:test_normalize_llm_settings_adds_default_prompts:Function]
|
||||
|
||||
|
||||
# [DEF:test_normalize_llm_settings_keeps_custom_prompt_values:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Ensure user-customized prompt values are preserved during normalization.
|
||||
# @PRE: Input llm settings contain custom prompt override.
|
||||
# @POST: Custom prompt value remains unchanged in normalized output.
|
||||
def test_normalize_llm_settings_keeps_custom_prompt_values():
|
||||
custom = "Doc for {dataset_name} using {columns_json}"
|
||||
normalized = normalize_llm_settings(
|
||||
{"prompts": {"documentation_prompt": custom}}
|
||||
)
|
||||
|
||||
assert normalized["prompts"]["documentation_prompt"] == custom
|
||||
# [/DEF:test_normalize_llm_settings_keeps_custom_prompt_values:Function]
|
||||
|
||||
|
||||
# [DEF:test_render_prompt_replaces_known_placeholders:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Ensure template placeholders are deterministically replaced.
|
||||
# @PRE: Template contains placeholders matching provided variables.
|
||||
# @POST: Rendered prompt string contains substituted values.
|
||||
def test_render_prompt_replaces_known_placeholders():
|
||||
rendered = render_prompt(
|
||||
"Hello {name}, diff={diff}",
|
||||
{"name": "bot", "diff": "A->B"},
|
||||
)
|
||||
|
||||
assert rendered == "Hello bot, diff=A->B"
|
||||
# [/DEF:test_render_prompt_replaces_known_placeholders:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.services.__tests__.test_llm_prompt_templates:Module]
|
||||
@@ -1,11 +1,9 @@
|
||||
# [DEF:backend.src.services.__tests__.test_resource_service:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: resource-service, tests, dashboards, datasets, activity
|
||||
# @PURPOSE: Unit tests for ResourceService
|
||||
# @LAYER: Service
|
||||
# @RELATION: TESTS -> backend.src.services.resource_service
|
||||
# @RELATION: VERIFIES -> ResourceService
|
||||
# @INVARIANT: Resource summaries preserve task linkage and status projection behavior.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
@@ -13,7 +11,6 @@ from datetime import datetime
|
||||
|
||||
|
||||
# [DEF:test_get_dashboards_with_status:Function]
|
||||
# @PURPOSE: Validate dashboard enrichment includes git/task status projections.
|
||||
# @TEST: get_dashboards_with_status returns dashboards with git and task status
|
||||
# @PRE: SupersetClient returns dashboard list
|
||||
# @POST: Each dashboard has git_status and last_task fields
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# [DEF:backend.src.services.llm_prompt_templates:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: llm, prompts, templates, settings
|
||||
# @PURPOSE: Provide default LLM prompt templates and normalization helpers for runtime usage.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.config_manager
|
||||
# @INVARIANT: All required prompt template keys are always present after normalization.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
# [DEF:DEFAULT_LLM_PROMPTS:Constant]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Default prompt templates used by documentation, dashboard validation, and git commit generation.
|
||||
DEFAULT_LLM_PROMPTS: Dict[str, str] = {
|
||||
"dashboard_validation_prompt": (
|
||||
"Analyze the attached dashboard screenshot and the following execution logs for health and visual issues.\n\n"
|
||||
"Logs:\n"
|
||||
"{logs}\n\n"
|
||||
"Provide the analysis in JSON format with the following structure:\n"
|
||||
"{\n"
|
||||
' "status": "PASS" | "WARN" | "FAIL",\n'
|
||||
' "summary": "Short summary of findings",\n'
|
||||
' "issues": [\n'
|
||||
" {\n"
|
||||
' "severity": "WARN" | "FAIL",\n'
|
||||
' "message": "Description of the issue",\n'
|
||||
' "location": "Optional location info (e.g. chart name)"\n'
|
||||
" }\n"
|
||||
" ]\n"
|
||||
"}"
|
||||
),
|
||||
"documentation_prompt": (
|
||||
"Generate professional documentation for the following dataset and its columns.\n"
|
||||
"Dataset: {dataset_name}\n"
|
||||
"Columns: {columns_json}\n\n"
|
||||
"Provide the documentation in JSON format:\n"
|
||||
"{\n"
|
||||
' "dataset_description": "General description of the dataset",\n'
|
||||
' "column_descriptions": [\n'
|
||||
" {\n"
|
||||
' "name": "column_name",\n'
|
||||
' "description": "Generated description"\n'
|
||||
" }\n"
|
||||
" ]\n"
|
||||
"}"
|
||||
),
|
||||
"git_commit_prompt": (
|
||||
"Generate a concise and professional git commit message based on the following diff and recent history.\n"
|
||||
"Use Conventional Commits format (e.g., feat: ..., fix: ..., docs: ...).\n\n"
|
||||
"Recent History:\n"
|
||||
"{history}\n\n"
|
||||
"Diff:\n"
|
||||
"{diff}\n\n"
|
||||
"Commit Message:"
|
||||
),
|
||||
}
|
||||
# [/DEF:DEFAULT_LLM_PROMPTS:Constant]
|
||||
|
||||
|
||||
# [DEF:normalize_llm_settings:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Ensure llm settings contain stable schema with prompts section and default templates.
|
||||
# @PRE: llm_settings is dictionary-like value or None.
|
||||
# @POST: Returned dict contains prompts with all required template keys.
|
||||
def normalize_llm_settings(llm_settings: Any) -> Dict[str, Any]:
|
||||
normalized: Dict[str, Any] = {"providers": [], "default_provider": "", "prompts": {}}
|
||||
if isinstance(llm_settings, dict):
|
||||
normalized.update({k: v for k, v in llm_settings.items() if k in ("providers", "default_provider", "prompts")})
|
||||
prompts = normalized.get("prompts") if isinstance(normalized.get("prompts"), dict) else {}
|
||||
merged_prompts = deepcopy(DEFAULT_LLM_PROMPTS)
|
||||
merged_prompts.update({k: v for k, v in prompts.items() if isinstance(v, str) and v.strip()})
|
||||
normalized["prompts"] = merged_prompts
|
||||
return normalized
|
||||
# [/DEF:normalize_llm_settings:Function]
|
||||
|
||||
|
||||
# [DEF:render_prompt:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Render prompt template using deterministic placeholder replacement with graceful fallback.
|
||||
# @PRE: template is a string and variables values are already stringifiable.
|
||||
# @POST: Returns rendered prompt text with known placeholders substituted.
|
||||
def render_prompt(template: str, variables: Dict[str, Any]) -> str:
|
||||
rendered = template
|
||||
for key, value in variables.items():
|
||||
rendered = rendered.replace("{" + key + "}", str(value))
|
||||
return rendered
|
||||
# [/DEF:render_prompt:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.services.llm_prompt_templates:Module]
|
||||
@@ -24,7 +24,7 @@ class EncryptionManager:
|
||||
# @PRE: ENCRYPTION_KEY env var must be set or use default dev key.
|
||||
# @POST: Fernet instance ready for encryption/decryption.
|
||||
def __init__(self):
|
||||
self.key = os.getenv("ENCRYPTION_KEY", "ZcytYzi0iHIl4Ttr-GdAEk117aGRogkGvN3wiTxrPpE=").encode()
|
||||
self.key = os.getenv("ENCRYPTION_KEY", "REMOVED_HISTORICAL_SECRET_DO_NOT_USE").encode()
|
||||
self.fernet = Fernet(self.key)
|
||||
# [/DEF:EncryptionManager.__init__:Function]
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# [DEF:backend.tests.test_report_normalizer:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, normalizer, fallback
|
||||
# @PURPOSE: Validate unknown task type fallback and partial payload normalization behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> backend.src.services.reports.normalizer
|
||||
# @INVARIANT: Unknown plugin types are mapped to canonical unknown task type.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.services.reports.normalizer import normalize_task_report
|
||||
|
||||
|
||||
def test_unknown_type_maps_to_unknown_profile():
|
||||
task = Task(
|
||||
id="unknown-1",
|
||||
plugin_id="custom-unmapped-plugin",
|
||||
status=TaskStatus.FAILED,
|
||||
started_at=datetime.utcnow(),
|
||||
finished_at=datetime.utcnow(),
|
||||
params={},
|
||||
result={"error_message": "Unexpected plugin payload"},
|
||||
)
|
||||
|
||||
report = normalize_task_report(task)
|
||||
|
||||
assert report.task_type.value == "unknown"
|
||||
assert report.summary
|
||||
assert report.error_context is not None
|
||||
|
||||
|
||||
def test_partial_payload_keeps_report_visible_with_placeholders():
|
||||
task = Task(
|
||||
id="partial-1",
|
||||
plugin_id="superset-backup",
|
||||
status=TaskStatus.SUCCESS,
|
||||
started_at=datetime.utcnow(),
|
||||
finished_at=datetime.utcnow(),
|
||||
params={},
|
||||
result=None,
|
||||
)
|
||||
|
||||
report = normalize_task_report(task)
|
||||
|
||||
assert report.task_type.value == "backup"
|
||||
assert report.details is not None
|
||||
assert "result" in report.details
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_report_normalizer:Module]
|
||||
@@ -1,152 +0,0 @@
|
||||
# [DEF:backend.src.services.reports.normalizer:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, normalization, tasks, fallback
|
||||
# @PURPOSE: Convert task manager task objects into canonical unified TaskReport entities with deterministic fallback behavior.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models.Task
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.report
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.reports.type_profiles
|
||||
# @INVARIANT: Unknown task types and partial payloads remain visible via fallback mapping.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ...core.task_manager.models import Task, TaskStatus
|
||||
from ...models.report import ErrorContext, ReportStatus, TaskReport
|
||||
from .type_profiles import get_type_profile, resolve_task_type
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:status_to_report_status:Function]
|
||||
# @PURPOSE: Normalize internal task status to canonical report status.
|
||||
# @PRE: status may be known or unknown string/enum value.
|
||||
# @POST: Always returns one of canonical ReportStatus values.
|
||||
# @PARAM: status (Any) - Internal task status value.
|
||||
# @RETURN: ReportStatus - Canonical report status.
|
||||
def status_to_report_status(status: Any) -> ReportStatus:
|
||||
raw = str(status.value if isinstance(status, TaskStatus) else status).upper()
|
||||
if raw == TaskStatus.SUCCESS.value:
|
||||
return ReportStatus.SUCCESS
|
||||
if raw == TaskStatus.FAILED.value:
|
||||
return ReportStatus.FAILED
|
||||
if raw in {TaskStatus.PENDING.value, TaskStatus.RUNNING.value, TaskStatus.AWAITING_INPUT.value, TaskStatus.AWAITING_MAPPING.value}:
|
||||
return ReportStatus.IN_PROGRESS
|
||||
return ReportStatus.PARTIAL
|
||||
# [/DEF:status_to_report_status:Function]
|
||||
|
||||
|
||||
# [DEF:build_summary:Function]
|
||||
# @PURPOSE: Build deterministic user-facing summary from task payload and status.
|
||||
# @PRE: report_status is canonical; plugin_id may be unknown.
|
||||
# @POST: Returns non-empty summary text.
|
||||
# @PARAM: task (Task) - Source task object.
|
||||
# @PARAM: report_status (ReportStatus) - Canonical status.
|
||||
# @RETURN: str - Normalized summary.
|
||||
def build_summary(task: Task, report_status: ReportStatus) -> str:
|
||||
result = task.result
|
||||
if isinstance(result, dict):
|
||||
for key in ("summary", "message", "status_message", "description"):
|
||||
value = result.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if report_status == ReportStatus.SUCCESS:
|
||||
return "Task completed successfully"
|
||||
if report_status == ReportStatus.FAILED:
|
||||
return "Task failed"
|
||||
if report_status == ReportStatus.IN_PROGRESS:
|
||||
return "Task is in progress"
|
||||
return "Task completed with partial data"
|
||||
# [/DEF:build_summary:Function]
|
||||
|
||||
|
||||
# [DEF:extract_error_context:Function]
|
||||
# @PURPOSE: Extract normalized error context and next actions for failed/partial reports.
|
||||
# @PRE: task is a valid Task object.
|
||||
# @POST: Returns ErrorContext for failed/partial when context exists; otherwise None.
|
||||
# @PARAM: task (Task) - Source task.
|
||||
# @PARAM: report_status (ReportStatus) - Canonical status.
|
||||
# @RETURN: Optional[ErrorContext] - Error context block.
|
||||
def extract_error_context(task: Task, report_status: ReportStatus) -> Optional[ErrorContext]:
|
||||
if report_status not in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
|
||||
return None
|
||||
|
||||
result = task.result if isinstance(task.result, dict) else {}
|
||||
message = None
|
||||
code = None
|
||||
next_actions = []
|
||||
|
||||
if isinstance(result.get("error"), dict):
|
||||
error_obj = result.get("error", {})
|
||||
message = error_obj.get("message") or message
|
||||
code = error_obj.get("code") or code
|
||||
actions = error_obj.get("next_actions")
|
||||
if isinstance(actions, list):
|
||||
next_actions = [str(action) for action in actions if str(action).strip()]
|
||||
|
||||
if not message:
|
||||
message = result.get("error_message") if isinstance(result.get("error_message"), str) else None
|
||||
|
||||
if not message:
|
||||
for log in reversed(task.logs):
|
||||
if str(log.level).upper() == "ERROR" and log.message:
|
||||
message = log.message
|
||||
break
|
||||
|
||||
if not message:
|
||||
message = "Not provided"
|
||||
|
||||
if not next_actions:
|
||||
next_actions = ["Review task diagnostics", "Retry the operation"]
|
||||
|
||||
return ErrorContext(code=code, message=message, next_actions=next_actions)
|
||||
# [/DEF:extract_error_context:Function]
|
||||
|
||||
|
||||
# [DEF:normalize_task_report:Function]
|
||||
# @PURPOSE: Convert one Task to canonical TaskReport envelope.
|
||||
# @PRE: task has valid id and plugin_id fields.
|
||||
# @POST: Returns TaskReport with required fields and deterministic fallback behavior.
|
||||
# @PARAM: task (Task) - Source task.
|
||||
# @RETURN: TaskReport - Canonical normalized report.
|
||||
def normalize_task_report(task: Task) -> TaskReport:
|
||||
task_type = resolve_task_type(task.plugin_id)
|
||||
report_status = status_to_report_status(task.status)
|
||||
profile = get_type_profile(task_type)
|
||||
|
||||
started_at = task.started_at if isinstance(task.started_at, datetime) else None
|
||||
updated_at = task.finished_at if isinstance(task.finished_at, datetime) else None
|
||||
if not updated_at:
|
||||
updated_at = started_at or datetime.utcnow()
|
||||
|
||||
details: Dict[str, Any] = {
|
||||
"profile": {
|
||||
"display_label": profile.get("display_label"),
|
||||
"visual_variant": profile.get("visual_variant"),
|
||||
"icon_token": profile.get("icon_token"),
|
||||
"emphasis_rules": profile.get("emphasis_rules", []),
|
||||
},
|
||||
"result": task.result if task.result is not None else {"note": "Not provided"},
|
||||
}
|
||||
|
||||
source_ref: Dict[str, Any] = {}
|
||||
if isinstance(task.params, dict):
|
||||
for key in ("environment_id", "source_env_id", "target_env_id", "dashboard_id", "dataset_id", "resource_id"):
|
||||
if key in task.params:
|
||||
source_ref[key] = task.params.get(key)
|
||||
|
||||
return TaskReport(
|
||||
report_id=task.id,
|
||||
task_id=task.id,
|
||||
task_type=task_type,
|
||||
status=report_status,
|
||||
started_at=started_at,
|
||||
updated_at=updated_at,
|
||||
summary=build_summary(task, report_status),
|
||||
details=details,
|
||||
error_context=extract_error_context(task, report_status),
|
||||
source_ref=source_ref or None,
|
||||
)
|
||||
# [/DEF:normalize_task_report:Function]
|
||||
|
||||
# [/DEF:backend.src.services.reports.normalizer:Module]
|
||||
@@ -1,195 +0,0 @@
|
||||
# [DEF:backend.src.services.reports.report_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, service, aggregation, filtering, pagination, detail
|
||||
# @PURPOSE: Aggregate, normalize, filter, and paginate task reports for unified list/detail API use cases.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.manager.TaskManager
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.report
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.reports.normalizer
|
||||
# @INVARIANT: List responses are deterministic and include applied filter echo metadata.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskReport, TaskType
|
||||
from .normalizer import normalize_task_report
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:ReportsService:Class]
|
||||
# @PURPOSE: Service layer for list/detail report retrieval and normalization.
|
||||
# @TIER: CRITICAL
|
||||
# @PRE: TaskManager dependency is initialized.
|
||||
# @POST: Provides deterministic list/detail report responses.
|
||||
# @INVARIANT: Service methods are read-only over task history source.
|
||||
class ReportsService:
|
||||
# [DEF:__init__:Function]
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Initialize service with TaskManager dependency.
|
||||
# @PRE: task_manager is a live TaskManager instance.
|
||||
# @POST: self.task_manager is assigned and ready for read operations.
|
||||
# @INVARIANT: Constructor performs no task mutations.
|
||||
# @PARAM: task_manager (TaskManager) - Task manager providing source task history.
|
||||
def __init__(self, task_manager: TaskManager):
|
||||
self.task_manager = task_manager
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:_load_normalized_reports:Function]
|
||||
# @PURPOSE: Build normalized reports from all available tasks.
|
||||
# @PRE: Task manager returns iterable task history records.
|
||||
# @POST: Returns normalized report list preserving source cardinality.
|
||||
# @INVARIANT: Every returned item is a TaskReport.
|
||||
# @RETURN: List[TaskReport] - Reports sorted later by list logic.
|
||||
def _load_normalized_reports(self) -> List[TaskReport]:
|
||||
tasks = self.task_manager.get_all_tasks()
|
||||
reports = [normalize_task_report(task) for task in tasks]
|
||||
return reports
|
||||
# [/DEF:_load_normalized_reports:Function]
|
||||
|
||||
# [DEF:_to_utc_datetime:Function]
|
||||
# @PURPOSE: Normalize naive/aware datetime values to UTC-aware datetime for safe comparisons.
|
||||
# @PRE: value is either datetime or None.
|
||||
# @POST: Returns UTC-aware datetime or None.
|
||||
# @INVARIANT: Naive datetimes are interpreted as UTC to preserve deterministic ordering/filtering.
|
||||
# @PARAM: value (Optional[datetime]) - Source datetime value.
|
||||
# @RETURN: Optional[datetime] - UTC-aware datetime or None.
|
||||
def _to_utc_datetime(self, value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
# [/DEF:_to_utc_datetime:Function]
|
||||
|
||||
# [DEF:_datetime_sort_key:Function]
|
||||
# @PURPOSE: Produce stable numeric sort key for report timestamps.
|
||||
# @PRE: report contains updated_at datetime.
|
||||
# @POST: Returns float timestamp suitable for deterministic sorting.
|
||||
# @INVARIANT: Mixed naive/aware datetimes never raise TypeError.
|
||||
# @PARAM: report (TaskReport) - Report item.
|
||||
# @RETURN: float - UTC timestamp key.
|
||||
def _datetime_sort_key(self, report: TaskReport) -> float:
|
||||
updated = self._to_utc_datetime(report.updated_at)
|
||||
if updated is None:
|
||||
return 0.0
|
||||
return updated.timestamp()
|
||||
# [/DEF:_datetime_sort_key:Function]
|
||||
|
||||
# [DEF:_matches_query:Function]
|
||||
# @PURPOSE: Apply query filtering to a report.
|
||||
# @PRE: report and query are normalized schema instances.
|
||||
# @POST: Returns True iff report satisfies all active query filters.
|
||||
# @INVARIANT: Filter evaluation is side-effect free.
|
||||
# @PARAM: report (TaskReport) - Candidate report.
|
||||
# @PARAM: query (ReportQuery) - Applied query.
|
||||
# @RETURN: bool - True if report matches all filters.
|
||||
def _matches_query(self, report: TaskReport, query: ReportQuery) -> bool:
|
||||
if query.task_types and report.task_type not in query.task_types:
|
||||
return False
|
||||
if query.statuses and report.status not in query.statuses:
|
||||
return False
|
||||
report_updated_at = self._to_utc_datetime(report.updated_at)
|
||||
query_time_from = self._to_utc_datetime(query.time_from)
|
||||
query_time_to = self._to_utc_datetime(query.time_to)
|
||||
|
||||
if query_time_from and report_updated_at and report_updated_at < query_time_from:
|
||||
return False
|
||||
if query_time_to and report_updated_at and report_updated_at > query_time_to:
|
||||
return False
|
||||
if query.search:
|
||||
needle = query.search.lower()
|
||||
haystack = f"{report.summary} {report.task_type.value} {report.status.value}".lower()
|
||||
if needle not in haystack:
|
||||
return False
|
||||
return True
|
||||
# [/DEF:_matches_query:Function]
|
||||
|
||||
# [DEF:_sort_reports:Function]
|
||||
# @PURPOSE: Sort reports deterministically according to query settings.
|
||||
# @PRE: reports contains only TaskReport items.
|
||||
# @POST: Returns reports ordered by selected sort field and order.
|
||||
# @INVARIANT: Sorting criteria are deterministic for equal input.
|
||||
# @PARAM: reports (List[TaskReport]) - Filtered reports.
|
||||
# @PARAM: query (ReportQuery) - Sort config.
|
||||
# @RETURN: List[TaskReport] - Sorted reports.
|
||||
def _sort_reports(self, reports: List[TaskReport], query: ReportQuery) -> List[TaskReport]:
|
||||
reverse = query.sort_order == "desc"
|
||||
|
||||
if query.sort_by == "status":
|
||||
reports.sort(key=lambda item: item.status.value, reverse=reverse)
|
||||
elif query.sort_by == "task_type":
|
||||
reports.sort(key=lambda item: item.task_type.value, reverse=reverse)
|
||||
else:
|
||||
reports.sort(key=self._datetime_sort_key, reverse=reverse)
|
||||
|
||||
return reports
|
||||
# [/DEF:_sort_reports:Function]
|
||||
|
||||
# [DEF:list_reports:Function]
|
||||
# @PURPOSE: Return filtered, sorted, paginated report collection.
|
||||
# @PRE: query has passed schema validation.
|
||||
# @POST: Returns {items,total,page,page_size,has_next,applied_filters}.
|
||||
# @PARAM: query (ReportQuery) - List filters and pagination.
|
||||
# @RETURN: ReportCollection - Paginated unified reports payload.
|
||||
def list_reports(self, query: ReportQuery) -> ReportCollection:
|
||||
reports = self._load_normalized_reports()
|
||||
filtered = [report for report in reports if self._matches_query(report, query)]
|
||||
sorted_reports = self._sort_reports(filtered, query)
|
||||
|
||||
total = len(sorted_reports)
|
||||
start = (query.page - 1) * query.page_size
|
||||
end = start + query.page_size
|
||||
items = sorted_reports[start:end]
|
||||
has_next = end < total
|
||||
|
||||
return ReportCollection(
|
||||
items=items,
|
||||
total=total,
|
||||
page=query.page,
|
||||
page_size=query.page_size,
|
||||
has_next=has_next,
|
||||
applied_filters=query,
|
||||
)
|
||||
# [/DEF:list_reports:Function]
|
||||
|
||||
# [DEF:get_report_detail:Function]
|
||||
# @PURPOSE: Return one normalized report with timeline/diagnostics/next actions.
|
||||
# @PRE: report_id exists in normalized report set.
|
||||
# @POST: Returns normalized detail envelope with diagnostics and next actions where applicable.
|
||||
# @PARAM: report_id (str) - Stable report identifier.
|
||||
# @RETURN: Optional[ReportDetailView] - Detailed report or None if not found.
|
||||
def get_report_detail(self, report_id: str) -> Optional[ReportDetailView]:
|
||||
reports = self._load_normalized_reports()
|
||||
target = next((report for report in reports if report.report_id == report_id), None)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
timeline = []
|
||||
if target.started_at:
|
||||
timeline.append({"event": "started", "at": target.started_at.isoformat()})
|
||||
timeline.append({"event": "updated", "at": target.updated_at.isoformat()})
|
||||
|
||||
diagnostics = target.details or {}
|
||||
if not diagnostics:
|
||||
diagnostics = {"note": "Not provided"}
|
||||
if target.error_context:
|
||||
diagnostics["error_context"] = target.error_context.model_dump()
|
||||
|
||||
next_actions = []
|
||||
if target.error_context and target.error_context.next_actions:
|
||||
next_actions = target.error_context.next_actions
|
||||
elif target.status in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
|
||||
next_actions = ["Review diagnostics", "Retry task if applicable"]
|
||||
|
||||
return ReportDetailView(
|
||||
report=target,
|
||||
timeline=timeline,
|
||||
diagnostics=diagnostics,
|
||||
next_actions=next_actions,
|
||||
)
|
||||
# [/DEF:get_report_detail:Function]
|
||||
# [/DEF:ReportsService:Class]
|
||||
|
||||
# [/DEF:backend.src.services.reports.report_service:Module]
|
||||
@@ -1,91 +0,0 @@
|
||||
# [DEF:backend.src.services.reports.type_profiles:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, type_profiles, normalization, fallback
|
||||
# @PURPOSE: Deterministic mapping of plugin/task identifiers to canonical report task types and fallback profile metadata.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.report.TaskType
|
||||
# @INVARIANT: Unknown input always resolves to TaskType.UNKNOWN with a single fallback profile.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ...models.report import TaskType
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:PLUGIN_TO_TASK_TYPE:Data]
|
||||
# @PURPOSE: Maps plugin identifiers to normalized report task types.
|
||||
PLUGIN_TO_TASK_TYPE: Dict[str, TaskType] = {
|
||||
"llm_dashboard_validation": TaskType.LLM_VERIFICATION,
|
||||
"superset-backup": TaskType.BACKUP,
|
||||
"superset-migration": TaskType.MIGRATION,
|
||||
"documentation": TaskType.DOCUMENTATION,
|
||||
}
|
||||
# [/DEF:PLUGIN_TO_TASK_TYPE:Data]
|
||||
|
||||
# [DEF:TASK_TYPE_PROFILES:Data]
|
||||
# @PURPOSE: Profile metadata registry for each normalized task type.
|
||||
TASK_TYPE_PROFILES: Dict[TaskType, Dict[str, Any]] = {
|
||||
TaskType.LLM_VERIFICATION: {
|
||||
"display_label": "LLM Verification",
|
||||
"visual_variant": "llm",
|
||||
"icon_token": "sparkles",
|
||||
"emphasis_rules": ["summary", "status", "next_actions"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.BACKUP: {
|
||||
"display_label": "Backup",
|
||||
"visual_variant": "backup",
|
||||
"icon_token": "archive",
|
||||
"emphasis_rules": ["summary", "status", "updated_at"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.MIGRATION: {
|
||||
"display_label": "Migration",
|
||||
"visual_variant": "migration",
|
||||
"icon_token": "shuffle",
|
||||
"emphasis_rules": ["summary", "status", "error_context"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.DOCUMENTATION: {
|
||||
"display_label": "Documentation",
|
||||
"visual_variant": "documentation",
|
||||
"icon_token": "file-text",
|
||||
"emphasis_rules": ["summary", "status", "details"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.UNKNOWN: {
|
||||
"display_label": "Other / Unknown",
|
||||
"visual_variant": "unknown",
|
||||
"icon_token": "help-circle",
|
||||
"emphasis_rules": ["summary", "status"],
|
||||
"fallback": True,
|
||||
},
|
||||
}
|
||||
# [/DEF:TASK_TYPE_PROFILES:Data]
|
||||
|
||||
|
||||
# [DEF:resolve_task_type:Function]
|
||||
# @PURPOSE: Resolve canonical task type from plugin/task identifier with guaranteed fallback.
|
||||
# @PRE: plugin_id may be None or unknown.
|
||||
# @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.
|
||||
def resolve_task_type(plugin_id: Optional[str]) -> TaskType:
|
||||
normalized = (plugin_id or "").strip()
|
||||
if not normalized:
|
||||
return TaskType.UNKNOWN
|
||||
return PLUGIN_TO_TASK_TYPE.get(normalized, TaskType.UNKNOWN)
|
||||
# [/DEF:resolve_task_type:Function]
|
||||
|
||||
|
||||
# [DEF:get_type_profile:Function]
|
||||
# @PURPOSE: Return deterministic profile metadata for a task type.
|
||||
# @PRE: task_type may be known or unknown.
|
||||
# @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.
|
||||
def get_type_profile(task_type: TaskType) -> Dict[str, Any]:
|
||||
return TASK_TYPE_PROFILES.get(task_type, TASK_TYPE_PROFILES[TaskType.UNKNOWN])
|
||||
# [/DEF:get_type_profile:Function]
|
||||
|
||||
# [/DEF:backend.src.services.reports.type_profiles:Module]
|
||||
BIN
backend/tasks.db
BIN
backend/tasks.db
Binary file not shown.
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Debug script to test Superset API authentication"""
|
||||
|
||||
from pprint import pprint
|
||||
from src.core.superset_client import SupersetClient
|
||||
from src.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def main():
|
||||
print("Debugging Superset API authentication...")
|
||||
|
||||
config = ConfigManager()
|
||||
|
||||
# Select first available environment
|
||||
environments = config.get_environments()
|
||||
|
||||
if not environments:
|
||||
print("No environments configured")
|
||||
return
|
||||
|
||||
env = environments[0]
|
||||
print(f"\nTesting environment: {env.name}")
|
||||
print(f"URL: {env.url}")
|
||||
|
||||
try:
|
||||
# Test API client authentication
|
||||
print("\n--- Testing API Authentication ---")
|
||||
client = SupersetClient(env)
|
||||
tokens = client.authenticate()
|
||||
|
||||
print("\nAPI Auth Success!")
|
||||
print(f"Access Token: {tokens.get('access_token', 'N/A')}")
|
||||
print(f"CSRF Token: {tokens.get('csrf_token', 'N/A')}")
|
||||
|
||||
# Debug cookies from session
|
||||
print("\n--- Session Cookies ---")
|
||||
for cookie in client.network.session.cookies:
|
||||
print(f"{cookie.name}={cookie.value}")
|
||||
|
||||
# Test accessing UI via requests
|
||||
print("\n--- Testing UI Access ---")
|
||||
ui_url = env.url.rstrip('/').replace('/api/v1', '')
|
||||
print(f"UI URL: {ui_url}")
|
||||
|
||||
# Try to access UI home page
|
||||
ui_response = client.network.session.get(ui_url, timeout=30, allow_redirects=True)
|
||||
print(f"Status Code: {ui_response.status_code}")
|
||||
print(f"URL: {ui_response.url}")
|
||||
|
||||
# Check response headers
|
||||
print("\n--- Response Headers ---")
|
||||
pprint(dict(ui_response.headers))
|
||||
|
||||
print("\n--- Response Content Preview (200 chars) ---")
|
||||
print(repr(ui_response.text[:200]))
|
||||
|
||||
if ui_response.status_code == 200:
|
||||
print("\nUI Access: Success")
|
||||
|
||||
# Try to access a dashboard
|
||||
# For testing, just use the home page
|
||||
print("\n--- Checking if login is required ---")
|
||||
if "login" in ui_response.url.lower() or "login" in ui_response.text.lower():
|
||||
print("❌ Not logged in to UI")
|
||||
else:
|
||||
print("✅ Logged in to UI")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
print("\nStack Trace:")
|
||||
print(traceback.format_exc())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script to debug API key decryption issue."""
|
||||
|
||||
from src.core.database import SessionLocal
|
||||
from src.models.llm import LLMProvider
|
||||
from cryptography.fernet import Fernet
|
||||
import os
|
||||
|
||||
# Get the encryption key
|
||||
key = os.getenv("ENCRYPTION_KEY", "ZcytYzi0iHIl4Ttr-GdAEk117aGRogkGvN3wiTxrPpE=").encode()
|
||||
print(f"Encryption key (first 20 chars): {key[:20]}")
|
||||
print(f"Encryption key length: {len(key)}")
|
||||
|
||||
# Create Fernet instance
|
||||
fernet = Fernet(key)
|
||||
|
||||
# Get provider from database
|
||||
db = SessionLocal()
|
||||
provider = db.query(LLMProvider).filter(LLMProvider.id == '6c899741-4108-4196-aea4-f38ad2f0150e').first()
|
||||
|
||||
if provider:
|
||||
print("\nProvider found:")
|
||||
print(f" ID: {provider.id}")
|
||||
print(f" Name: {provider.name}")
|
||||
print(f" Encrypted API Key (first 50 chars): {provider.api_key[:50]}")
|
||||
print(f" Encrypted API Key Length: {len(provider.api_key)}")
|
||||
|
||||
# Test decryption
|
||||
print("\nAttempting decryption...")
|
||||
try:
|
||||
decrypted = fernet.decrypt(provider.api_key.encode()).decode()
|
||||
print("Decryption successful!")
|
||||
print(f" Decrypted key length: {len(decrypted)}")
|
||||
print(f" Decrypted key (first 8 chars): {decrypted[:8]}")
|
||||
print(f" Decrypted key is empty: {len(decrypted) == 0}")
|
||||
except Exception as e:
|
||||
print(f"Decryption failed with error: {e}")
|
||||
print(f"Error type: {type(e).__name__}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print("Provider not found")
|
||||
|
||||
db.close()
|
||||
@@ -1 +0,0 @@
|
||||
[{"key[": 20, ")\n\n# Create Fernet instance\nfernet = Fernet(key)\n\n# Test encrypting an empty string\nempty_encrypted = fernet.encrypt(b\"": ".", "print(f": "nEncrypted empty string: {empty_encrypted"}, {"test-api-key-12345\"\ntest_encrypted = fernet.encrypt(test_key.encode()).decode()\nprint(f": "nEncrypted test key: {test_encrypted"}, {"gAAAAABphhwSZie0OwXjJ78Fk-c4Uo6doNJXipX49AX7Bypzp4ohiRX3hXPXKb45R1vhNUOqbm6Ke3-eRwu_KdWMZ9chFBKmqw==\"\nprint(f": "nStored encrypted key: {stored_key"}, {"len(stored_key)}": "Check if stored key matches empty string encryption\nif stored_key == empty_encrypted:\n print(", "string!": "else:\n print(", "print(f": "mpty string encryption: {empty_encrypted"}, {"stored_key}": "Try to decrypt the stored key\ntry:\n decrypted = fernet.decrypt(stored_key.encode()).decode()\n print(f", "print(f": "ecrypted key length: {len(decrypted)"}, {")\nexcept Exception as e:\n print(f": "nDecryption failed with error: {e"}]
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"mixed_task_reports": {
|
||||
"description": "Mixed reports across all supported task types",
|
||||
"items": [
|
||||
{
|
||||
"report_id": "rep-001",
|
||||
"task_id": "task-001",
|
||||
"task_type": "llm_verification",
|
||||
"status": "success",
|
||||
"started_at": "2026-02-22T09:00:00Z",
|
||||
"updated_at": "2026-02-22T09:00:30Z",
|
||||
"summary": "LLM verification completed",
|
||||
"details": {
|
||||
"checks_performed": 12,
|
||||
"issues_found": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"report_id": "rep-002",
|
||||
"task_id": "task-002",
|
||||
"task_type": "backup",
|
||||
"status": "failed",
|
||||
"started_at": "2026-02-22T09:10:00Z",
|
||||
"updated_at": "2026-02-22T09:11:00Z",
|
||||
"summary": "Backup failed due to storage limit",
|
||||
"error_context": {
|
||||
"message": "Not enough disk space",
|
||||
"next_actions": ["Free storage", "Retry backup"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"report_id": "rep-003",
|
||||
"task_id": "task-003",
|
||||
"task_type": "migration",
|
||||
"status": "in_progress",
|
||||
"started_at": "2026-02-22T09:20:00Z",
|
||||
"updated_at": "2026-02-22T09:21:00Z",
|
||||
"summary": "Migration running",
|
||||
"details": {
|
||||
"progress_percent": 42
|
||||
}
|
||||
},
|
||||
{
|
||||
"report_id": "rep-004",
|
||||
"task_id": "task-004",
|
||||
"task_type": "documentation",
|
||||
"status": "partial",
|
||||
"started_at": "2026-02-22T09:30:00Z",
|
||||
"updated_at": "2026-02-22T09:31:00Z",
|
||||
"summary": "Documentation generated with partial coverage",
|
||||
"error_context": {
|
||||
"message": "Missing metadata for 3 columns",
|
||||
"next_actions": ["Review missing metadata"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"unknown_type_partial_payload": {
|
||||
"description": "Unknown type and partial payload fallback coverage",
|
||||
"items": [
|
||||
{
|
||||
"report_id": "rep-unknown-001",
|
||||
"task_id": "task-unknown-001",
|
||||
"task_type": "unknown",
|
||||
"status": "failed",
|
||||
"updated_at": "2026-02-22T10:00:00Z",
|
||||
"summary": "Unknown task type failed",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"report_id": "rep-partial-001",
|
||||
"task_id": "task-partial-001",
|
||||
"task_type": "backup",
|
||||
"status": "success",
|
||||
"updated_at": "2026-02-22T10:05:00Z",
|
||||
"summary": "Backup completed",
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
29
build.sh
29
build.sh
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: docker is not installed or not in PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker-compose)
|
||||
else
|
||||
echo "Error: docker compose is not available."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/2] Building project images..."
|
||||
"${COMPOSE_CMD[@]}" build
|
||||
|
||||
echo "[2/2] Starting Docker services..."
|
||||
"${COMPOSE_CMD[@]}" up -d
|
||||
|
||||
echo "Done. Services are running."
|
||||
echo "Use '${COMPOSE_CMD[*]} ps' to check status and '${COMPOSE_CMD[*]} logs -f' to stream logs."
|
||||
@@ -17,11 +17,11 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
container_name: ss_tools_backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: ss_tools_app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
@@ -33,22 +33,10 @@ services:
|
||||
AUTH_DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
|
||||
BACKEND_PORT: 8000
|
||||
ports:
|
||||
- "${BACKEND_HOST_PORT:-8001}:8000"
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json
|
||||
- ./backups:/app/backups
|
||||
- ./backend/git_repos:/app/backend/git_repos
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
container_name: ss_tools_frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_HOST_PORT:-8000}:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/frontend/build /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -1,31 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,10 @@ The application moves from a **Task-Centric** model (where users navigate to "Mi
|
||||
`[Home] [Migration] [Git Manager] [Mapper] [Settings] [Logout]`
|
||||
|
||||
**New Menu:**
|
||||
`[Superset Manager] [Dashboards] [Datasets] [Reports] [Storage] | [Activity (0)] [Settings] [User]`
|
||||
`[Superset Manager] [Dashboards] [Datasets] [Storage] | [Activity (0)] [Settings] [User]`
|
||||
|
||||
* **Dashboards**: Main hub for all dashboard operations (Migrate, Backup, Git).
|
||||
* **Datasets**: Hub for dataset documentation and mapping.
|
||||
* **Reports**: Unified center for all task outcomes with type-distinct visual profiles and detail diagnostics.
|
||||
* **Storage**: File management (Backups, Repositories).
|
||||
* **Activity**: Global indicator of running tasks. Clicking it opens the Task Drawer.
|
||||
|
||||
|
||||
@@ -39,23 +39,6 @@ The settings API is available at `/settings`:
|
||||
|
||||
The settings page is located at `frontend/src/pages/Settings.svelte`. It provides forms for managing global settings and Superset environments.
|
||||
|
||||
## Reports Center
|
||||
|
||||
Unified reports are available at [`/reports`](frontend/src/routes/reports/+page.svelte) and use the backend API at [`/api/reports`](backend/src/api/routes/reports.py) and [`/api/reports/{report_id}`](backend/src/api/routes/reports.py).
|
||||
|
||||
### What operators can do
|
||||
|
||||
- View all task outcomes (LLM verification, backup, migration, documentation) in one list.
|
||||
- Filter by type and status.
|
||||
- Open report detail with diagnostics and recommended next actions.
|
||||
- Continue working even for unknown task types and partial payloads (explicit placeholders are shown instead of hidden data).
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- If report list is empty, verify tasks exist and clear filters.
|
||||
- If report detail is not found (404), confirm the selected report still exists in task history.
|
||||
- If report API tests fail during local execution with database connectivity errors, ensure the configured DB is reachable or run in an environment with available test DB services.
|
||||
|
||||
## Integration
|
||||
|
||||
Existing plugins and utilities use the `ConfigManager` to fetch configuration:
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -26,18 +26,18 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let filterText = $state("");
|
||||
let currentPage = $state(0);
|
||||
let pageSize = $state(20);
|
||||
let sortColumn: keyof DashboardMetadata = $state("title");
|
||||
let sortDirection: "asc" | "desc" = $state("asc");
|
||||
let filterText = "";
|
||||
let currentPage = 0;
|
||||
let pageSize = 20;
|
||||
let sortColumn: keyof DashboardMetadata = "title";
|
||||
let sortDirection: "asc" | "desc" = "asc";
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: UI STATE]
|
||||
let showGitManager = $state(false);
|
||||
let gitDashboardId: number | null = $state(null);
|
||||
let gitDashboardTitle = $state("");
|
||||
let validatingIds: Set<number> = $state(new Set());
|
||||
let showGitManager = false;
|
||||
let gitDashboardId: number | null = null;
|
||||
let gitDashboardTitle = "";
|
||||
let validatingIds: Set<number> = new Set();
|
||||
// [/SECTION]
|
||||
|
||||
// [DEF:handleValidate:Function]
|
||||
@@ -48,7 +48,7 @@
|
||||
if (validatingIds.has(dashboard.id)) return;
|
||||
|
||||
validatingIds.add(dashboard.id);
|
||||
validatingIds = new Set(validatingIds);
|
||||
validatingIds = validatingIds; // Trigger reactivity
|
||||
|
||||
try {
|
||||
// TODO: Get provider_id from settings or prompt user
|
||||
@@ -83,7 +83,7 @@
|
||||
toast(e.message || "Validation failed to start", "error");
|
||||
} finally {
|
||||
validatingIds.delete(dashboard.id);
|
||||
validatingIds = new Set(validatingIds);
|
||||
validatingIds = validatingIds;
|
||||
}
|
||||
}
|
||||
// [/DEF:handleValidate:Function]
|
||||
@@ -221,14 +221,14 @@
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected && !allSelected}
|
||||
onchange={(e) =>
|
||||
on:change={(e) =>
|
||||
handleSelectAll((e.target as HTMLInputElement).checked)}
|
||||
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
|
||||
onclick={() => handleSort("title")}
|
||||
on:click={() => handleSort("title")}
|
||||
>
|
||||
{$t.dashboard.title}
|
||||
{sortColumn === "title"
|
||||
@@ -239,7 +239,7 @@
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
|
||||
onclick={() => handleSort("last_modified")}
|
||||
on:click={() => handleSort("last_modified")}
|
||||
>
|
||||
{$t.dashboard.last_modified}
|
||||
{sortColumn === "last_modified"
|
||||
@@ -250,7 +250,7 @@
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
|
||||
onclick={() => handleSort("status")}
|
||||
on:click={() => handleSort("status")}
|
||||
>
|
||||
{$t.dashboard.status}
|
||||
{sortColumn === "status"
|
||||
@@ -276,7 +276,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(dashboard.id)}
|
||||
onchange={(e) =>
|
||||
on:change={(e) =>
|
||||
handleSelectionChange(
|
||||
dashboard.id,
|
||||
(e.target as HTMLInputElement).checked,
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
let selectedTargetUuid = $state("");
|
||||
let selectedTargetUuid = "";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:resolve:Function]
|
||||
@@ -94,7 +94,7 @@
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button
|
||||
type="button"
|
||||
onclick={resolve}
|
||||
on:click={resolve}
|
||||
disabled={!selectedTargetUuid}
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
|
||||
>
|
||||
@@ -102,7 +102,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancel}
|
||||
on:click={cancel}
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
|
||||
>
|
||||
Cancel Migration
|
||||
|
||||
@@ -34,10 +34,10 @@
|
||||
{$t.nav.dashboard}
|
||||
</a>
|
||||
<a
|
||||
href="/reports"
|
||||
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/reports') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
|
||||
href="/tasks"
|
||||
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/tasks') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
|
||||
>
|
||||
{$t.nav.reports}
|
||||
{$t.nav.tasks}
|
||||
</a>
|
||||
<div class="relative inline-block group">
|
||||
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/settings') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
let {
|
||||
tasks = [],
|
||||
loading = false,
|
||||
selectedTaskId = null,
|
||||
} = $props();
|
||||
|
||||
|
||||
@@ -55,8 +54,8 @@
|
||||
// @PURPOSE: Dispatches a select event when a task is clicked.
|
||||
// @PRE: taskId is provided.
|
||||
// @POST: 'select' event is dispatched with task ID.
|
||||
function handleTaskClick(task: any) {
|
||||
dispatch('select', { id: task.id, task });
|
||||
function handleTaskClick(taskId: string) {
|
||||
dispatch('select', { id: taskId });
|
||||
}
|
||||
// [/DEF:handleTaskClick:Function]
|
||||
</script>
|
||||
@@ -71,8 +70,8 @@
|
||||
{#each tasks as task (task.id)}
|
||||
<li>
|
||||
<button
|
||||
class="block w-full text-left transition duration-150 ease-in-out focus:outline-none hover:bg-gray-50 {selectedTaskId === task.id ? 'bg-blue-50' : ''}"
|
||||
on:click={() => handleTaskClick(task)}
|
||||
class="block hover:bg-gray-50 w-full text-left transition duration-150 ease-in-out focus:outline-none"
|
||||
on:click={() => handleTaskClick(task.id)}
|
||||
>
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -111,4 +110,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:TaskList:Component] -->
|
||||
<!-- [/DEF:TaskList:Component] -->
|
||||
@@ -27,10 +27,10 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let branches = $state([]);
|
||||
let loading = $state(false);
|
||||
let showCreate = $state(false);
|
||||
let newBranchName = $state('');
|
||||
let branches = [];
|
||||
let loading = false;
|
||||
let showCreate = false;
|
||||
let newBranchName = '';
|
||||
// [/SECTION]
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -129,7 +129,7 @@
|
||||
<div class="flex-grow">
|
||||
<Select
|
||||
bind:value={currentBranch}
|
||||
onchange={handleSelect}
|
||||
on:change={handleSelect}
|
||||
disabled={loading}
|
||||
options={branches.map(b => ({ value: b.name, label: b.name }))}
|
||||
/>
|
||||
@@ -138,7 +138,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => showCreate = !showCreate}
|
||||
on:click={() => showCreate = !showCreate}
|
||||
disabled={loading}
|
||||
class="text-blue-600"
|
||||
>
|
||||
@@ -158,7 +158,7 @@
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onclick={handleCreate}
|
||||
on:click={handleCreate}
|
||||
disabled={loading || !newBranchName}
|
||||
isLoading={loading}
|
||||
class="bg-green-600 hover:bg-green-700"
|
||||
@@ -168,7 +168,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => showCreate = false}
|
||||
on:click={() => showCreate = false}
|
||||
disabled={loading}
|
||||
>
|
||||
{$t.common.cancel}
|
||||
@@ -178,4 +178,4 @@
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:BranchSelector:Component] -->
|
||||
<!-- [/DEF:BranchSelector:Component] -->
|
||||
@@ -23,8 +23,8 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let history = $state([]);
|
||||
let loading = $state(false);
|
||||
let history = [];
|
||||
let loading = false;
|
||||
// [/SECTION]
|
||||
|
||||
// [DEF:onMount:Function]
|
||||
@@ -66,7 +66,7 @@
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">
|
||||
{$t.git.history}
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onclick={loadHistory} class="text-blue-600">
|
||||
<Button variant="ghost" size="sm" on:click={loadHistory} class="text-blue-600">
|
||||
{$t.git.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -95,4 +95,4 @@
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:CommitHistory:Component] -->
|
||||
<!-- [/DEF:CommitHistory:Component] -->
|
||||
@@ -24,12 +24,12 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let message = $state("");
|
||||
let committing = $state(false);
|
||||
let status = $state(null);
|
||||
let diff = $state("");
|
||||
let loading = $state(false);
|
||||
let generatingMessage = $state(false);
|
||||
let message = "";
|
||||
let committing = false;
|
||||
let status = null;
|
||||
let diff = "";
|
||||
let loading = false;
|
||||
let generatingMessage = false;
|
||||
// [/SECTION]
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -153,7 +153,7 @@
|
||||
>Commit Message</label
|
||||
>
|
||||
<button
|
||||
onclick={handleGenerateMessage}
|
||||
on:click={handleGenerateMessage}
|
||||
disabled={generatingMessage || loading}
|
||||
class="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
@@ -243,13 +243,13 @@
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6 pt-4 border-t">
|
||||
<button
|
||||
onclick={() => (show = false)}
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleCommit}
|
||||
on:click={handleCommit}
|
||||
disabled={committing ||
|
||||
!message ||
|
||||
loading ||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
// [SECTION: STATE]
|
||||
const dispatch = createEventDispatcher();
|
||||
/** @type {Object.<string, 'mine' | 'theirs' | 'manual'>} */
|
||||
let resolutions = $state({});
|
||||
let resolutions = {};
|
||||
// [/SECTION]
|
||||
|
||||
// [DEF:resolve:Function]
|
||||
@@ -126,7 +126,7 @@
|
||||
] === 'mine'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-50 hover:bg-blue-50 text-blue-600'}"
|
||||
onclick={() =>
|
||||
on:click={() =>
|
||||
resolve(conflict.file_path, "mine")}
|
||||
>
|
||||
Keep Mine
|
||||
@@ -148,7 +148,7 @@
|
||||
] === 'theirs'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-50 hover:bg-green-50 text-green-600'}"
|
||||
onclick={() =>
|
||||
on:click={() =>
|
||||
resolve(conflict.file_path, "theirs")}
|
||||
>
|
||||
Keep Theirs
|
||||
@@ -161,13 +161,13 @@
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
onclick={() => (show = false)}
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
on:click={handleSave}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors shadow-sm"
|
||||
>
|
||||
Resolve & Continue
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let environments = $state([]);
|
||||
let selectedEnv = $state("");
|
||||
let loading = $state(false);
|
||||
let deploying = $state(false);
|
||||
let environments = [];
|
||||
let selectedEnv = "";
|
||||
let loading = false;
|
||||
let deploying = false;
|
||||
// [/SECTION]
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -108,7 +108,7 @@
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={() => (show = false)}
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
|
||||
>
|
||||
Close
|
||||
@@ -133,13 +133,13 @@
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
onclick={() => (show = false)}
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDeploy}
|
||||
on:click={handleDeploy}
|
||||
disabled={deploying || !selectedEnv}
|
||||
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
|
||||
@@ -35,20 +35,20 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let currentBranch = $state('main');
|
||||
let showCommitModal = $state(false);
|
||||
let showDeployModal = $state(false);
|
||||
let currentBranch = 'main';
|
||||
let showCommitModal = false;
|
||||
let showDeployModal = false;
|
||||
let showHistory = true;
|
||||
let showConflicts = $state(false);
|
||||
let showConflicts = false;
|
||||
let conflicts = [];
|
||||
let loading = $state(false);
|
||||
let initialized = $state(false);
|
||||
let checkingStatus = $state(true);
|
||||
let loading = false;
|
||||
let initialized = false;
|
||||
let checkingStatus = true;
|
||||
|
||||
// Initialization form state
|
||||
let configs = $state([]);
|
||||
let selectedConfigId = $state("");
|
||||
let remoteUrl = $state("");
|
||||
let configs = [];
|
||||
let selectedConfigId = "";
|
||||
let remoteUrl = "";
|
||||
// [/SECTION]
|
||||
|
||||
// [DEF:checkStatus:Function]
|
||||
@@ -167,7 +167,7 @@
|
||||
<PageHeader title="{$t.git.management}: {dashboardTitle}">
|
||||
<div slot="subtitle" class="text-sm text-gray-500">ID: {dashboardId}</div>
|
||||
<div slot="actions">
|
||||
<button onclick={() => show = false} class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<button on:click={() => show = false} class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -203,7 +203,7 @@
|
||||
/>
|
||||
|
||||
<Button
|
||||
onclick={handleInit}
|
||||
on:click={handleInit}
|
||||
disabled={loading || configs.length === 0}
|
||||
isLoading={loading}
|
||||
class="w-full"
|
||||
@@ -226,14 +226,14 @@
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">{$t.git.actions}</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={handleSync}
|
||||
on:click={handleSync}
|
||||
disabled={loading}
|
||||
class="w-full"
|
||||
>
|
||||
{$t.git.sync}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => showCommitModal = true}
|
||||
on:click={() => showCommitModal = true}
|
||||
disabled={loading}
|
||||
class="w-full"
|
||||
>
|
||||
@@ -242,7 +242,7 @@
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={handlePull}
|
||||
on:click={handlePull}
|
||||
disabled={loading}
|
||||
class="border border-gray-200"
|
||||
>
|
||||
@@ -250,7 +250,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={handlePush}
|
||||
on:click={handlePush}
|
||||
disabled={loading}
|
||||
class="border border-gray-200"
|
||||
>
|
||||
@@ -263,7 +263,7 @@
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">{$t.git.deployment}</h3>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => showDeployModal = true}
|
||||
on:click={() => showDeployModal = true}
|
||||
disabled={loading}
|
||||
class="w-full bg-green-600 hover:bg-green-700 focus-visible:ring-green-500"
|
||||
>
|
||||
@@ -300,4 +300,4 @@
|
||||
/>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:GitManager:Component] -->
|
||||
<!-- [/DEF:GitManager:Component] -->
|
||||
@@ -11,17 +11,13 @@
|
||||
|
||||
/** @type {Object} */
|
||||
let {
|
||||
documentation = null,
|
||||
content = "",
|
||||
type = 'markdown',
|
||||
format = 'text',
|
||||
onSave = async () => {},
|
||||
onCancel = () => {},
|
||||
} = $props();
|
||||
|
||||
let previewDoc = $derived(documentation || content);
|
||||
|
||||
let isSaving = $state(false);
|
||||
let isSaving = false;
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
@@ -35,14 +31,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if previewDoc}
|
||||
{#if documentation}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<h3 class="text-lg font-semibold mb-4">{$t.llm.doc_preview_title}</h3>
|
||||
|
||||
<div class="flex-1 overflow-y-auto mb-6 prose prose-sm max-w-none border rounded p-4 bg-gray-50">
|
||||
<h4 class="text-md font-bold text-gray-800 mb-2">{$t.llm.dataset_desc}</h4>
|
||||
<p class="text-gray-700 mb-4 whitespace-pre-wrap">{previewDoc.description || 'No description generated.'}</p>
|
||||
<p class="text-gray-700 mb-4 whitespace-pre-wrap">{documentation.description || 'No description generated.'}</p>
|
||||
|
||||
<h4 class="text-md font-bold text-gray-800 mb-2">{$t.llm.column_doc}</h4>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
@@ -53,7 +49,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{#each Object.entries(previewDoc.columns || {}) as [name, desc]}
|
||||
{#each Object.entries(documentation.columns || {}) as [name, desc]}
|
||||
<tr>
|
||||
<td class="px-3 py-2 text-sm font-mono text-gray-900">{name}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-700">{desc}</td>
|
||||
@@ -66,14 +62,14 @@
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
class="px-4 py-2 border rounded hover:bg-gray-50"
|
||||
onclick={onCancel}
|
||||
on:click={onCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{$t.llm.cancel}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
onclick={handleSave}
|
||||
on:click={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? $t.llm.applying : $t.llm.apply_doc}
|
||||
@@ -83,4 +79,4 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- [/DEF:DocPreview:Component] -->
|
||||
<!-- [/DEF:DocPreview:Component] -->
|
||||
@@ -7,12 +7,12 @@
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { requestApi } from "../../lib/api";
|
||||
|
||||
/** @type {Array} */
|
||||
export let providers = [];
|
||||
export let onSave = () => {};
|
||||
let { providers = [], onSave = () => {} } = $props();
|
||||
|
||||
let editingProvider = null;
|
||||
let showForm = false;
|
||||
@@ -43,18 +43,8 @@
|
||||
}
|
||||
|
||||
function handleEdit(provider) {
|
||||
console.log("[ProviderConfig][Action] Editing provider", provider?.id);
|
||||
editingProvider = provider;
|
||||
// Normalize provider fields to editable form shape.
|
||||
formData = {
|
||||
name: provider?.name ?? "",
|
||||
provider_type: provider?.provider_type ?? "openai",
|
||||
base_url: provider?.base_url ?? "https://api.openai.com/v1",
|
||||
api_key: "",
|
||||
default_model: provider?.default_model ?? "gpt-4o",
|
||||
is_active: Boolean(provider?.is_active),
|
||||
};
|
||||
testStatus = { type: "", message: "" };
|
||||
formData = { ...provider, api_key: "" }; // Don't populate key for security
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
@@ -131,22 +121,19 @@
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold">{$t.llm.providers_title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
|
||||
on:click|preventDefault={() => {
|
||||
on:click={() => {
|
||||
resetForm();
|
||||
showForm = true;
|
||||
}}
|
||||
>
|
||||
{$t.llm.add_provider}
|
||||
{$t.llm.add_provider}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-md">
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
@@ -254,26 +241,23 @@
|
||||
|
||||
<div class="mt-6 flex justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 border rounded hover:bg-gray-50 flex-1"
|
||||
on:click|preventDefault={() => {
|
||||
on:click={() => {
|
||||
showForm = false;
|
||||
}}
|
||||
>
|
||||
{$t.llm.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 flex-1"
|
||||
disabled={isTesting}
|
||||
on:click|preventDefault={testConnection}
|
||||
on:click={testConnection}
|
||||
>
|
||||
{isTesting ? $t.llm.testing : $t.llm.test}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex-1"
|
||||
on:click|preventDefault={handleSubmit}
|
||||
on:click={handleSubmit}
|
||||
>
|
||||
{$t.llm.save}
|
||||
</button>
|
||||
@@ -302,16 +286,14 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-blue-600 hover:underline"
|
||||
on:click|preventDefault|stopPropagation={() => handleEdit(provider)}
|
||||
on:click={() => handleEdit(provider)}
|
||||
>
|
||||
{$t.common.edit}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`}
|
||||
on:click|preventDefault|stopPropagation={() => toggleActive(provider)}
|
||||
on:click={() => toggleActive(provider)}
|
||||
>
|
||||
{provider.is_active ? "Deactivate" : "Activate"}
|
||||
</button>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// [DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: llm, provider-config, integration-test, edit-flow
|
||||
// @PURPOSE: Protect edit-button interaction contract in LLM provider settings UI.
|
||||
// @LAYER: UI Tests
|
||||
// @RELATION: VERIFIES -> frontend/src/components/llm/ProviderConfig.svelte
|
||||
// @INVARIANT: Edit action keeps explicit click handler and opens normalized edit form.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const COMPONENT_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/components/llm/ProviderConfig.svelte',
|
||||
);
|
||||
|
||||
// [DEF:provider_config_edit_contract_tests:Function]
|
||||
// @TIER: STANDARD
|
||||
// @PURPOSE: Validate edit button handler wiring and normalized edit form state mapping.
|
||||
// @PRE: ProviderConfig component source exists in expected path.
|
||||
// @POST: Contract checks ensure edit click cannot degrade into no-op flow.
|
||||
describe('ProviderConfig edit interaction contract', () => {
|
||||
it('keeps explicit edit click handler with guarded button semantics', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('type="button"');
|
||||
expect(source).toContain(
|
||||
"on:click|preventDefault|stopPropagation={() => handleEdit(provider)}",
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes provider payload into editable form shape', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('formData = {');
|
||||
expect(source).toContain('name: provider?.name ?? ""');
|
||||
expect(source).toContain('provider_type: provider?.provider_type ?? "openai"');
|
||||
expect(source).toContain('default_model: provider?.default_model ?? "gpt-4o"');
|
||||
expect(source).toContain('showForm = true;');
|
||||
});
|
||||
});
|
||||
// [/DEF:provider_config_edit_contract_tests:Function]
|
||||
// [/DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]
|
||||
@@ -31,8 +31,8 @@
|
||||
path = '',
|
||||
} = $props();
|
||||
|
||||
let isUploading = $state(false);
|
||||
let dragOver = $state(false);
|
||||
let isUploading = false;
|
||||
let dragOver = false;
|
||||
|
||||
async function handleUpload() {
|
||||
const file = fileInput.files[0];
|
||||
@@ -94,9 +94,9 @@
|
||||
<div
|
||||
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-dashed rounded-md transition-colors
|
||||
{dragOver ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300'}"
|
||||
ondragover={(event) => { event.preventDefault(); dragOver = true; }}
|
||||
ondragleave={(event) => { event.preventDefault(); dragOver = false; }}
|
||||
ondrop={(event) => { event.preventDefault(); handleDrop(event); }}
|
||||
on:dragover|preventDefault={() => dragOver = true}
|
||||
on:dragleave|preventDefault={() => dragOver = false}
|
||||
on:drop|preventDefault={handleDrop}
|
||||
>
|
||||
<div class="space-y-1 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
@@ -111,7 +111,7 @@
|
||||
type="file"
|
||||
class="sr-only"
|
||||
bind:this={fileInput}
|
||||
onchange={handleUpload}
|
||||
on:change={handleUpload}
|
||||
disabled={isUploading}
|
||||
>
|
||||
</label>
|
||||
@@ -132,4 +132,4 @@
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
|
||||
<!-- [/DEF:FileUpload:Component] -->
|
||||
<!-- [/DEF:FileUpload:Component] -->
|
||||
@@ -1,114 +0,0 @@
|
||||
<script>
|
||||
let { task = null } = $props();
|
||||
|
||||
const result = $derived(task?.result || null);
|
||||
const pluginId = $derived(task?.plugin_id || '');
|
||||
|
||||
function statusColor(status) {
|
||||
switch (status) {
|
||||
case 'PASS':
|
||||
case 'SUCCESS':
|
||||
return 'bg-green-100 text-green-700';
|
||||
case 'WARN':
|
||||
case 'PARTIAL_SUCCESS':
|
||||
return 'bg-yellow-100 text-yellow-700';
|
||||
case 'FAIL':
|
||||
case 'FAILED':
|
||||
return 'bg-red-100 text-red-700';
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-700';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !task}
|
||||
<div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 p-6 text-sm text-slate-500">
|
||||
Выберите задачу, чтобы увидеть результат.
|
||||
</div>
|
||||
{:else if !result}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<p class="text-sm text-slate-700">Для этой задачи нет структурированного результата.</p>
|
||||
</div>
|
||||
{:else if pluginId === 'llm_dashboard_validation'}
|
||||
<div class="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-900">LLM проверка дашборда</h3>
|
||||
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${statusColor(result.status)}`}>{result.status || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-700">{result.summary || 'Нет summary'}</p>
|
||||
{#if result.issues?.length}
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Проблемы ({result.issues.length})</p>
|
||||
<ul class="space-y-2">
|
||||
{#each result.issues as issue}
|
||||
<li class="rounded-md border border-slate-200 bg-slate-50 p-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`rounded px-2 py-0.5 text-xs font-semibold ${statusColor(issue.severity)}`}>{issue.severity}</span>
|
||||
<span class="text-slate-700">{issue.message}</span>
|
||||
</div>
|
||||
{#if issue.location}
|
||||
<p class="mt-1 text-xs text-slate-500">Локация: {issue.location}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if pluginId === 'superset-backup'}
|
||||
<div class="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Результат бэкапа</h3>
|
||||
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${statusColor(result.status)}`}>{result.status || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm text-slate-700">
|
||||
<p>Environment: {result.environment || '-'}</p>
|
||||
<p>Total: {result.total_dashboards ?? 0}</p>
|
||||
<p>Успешно: {result.backed_up_dashboards ?? 0}</p>
|
||||
<p>Ошибок: {result.failed_dashboards ?? 0}</p>
|
||||
</div>
|
||||
{#if result.failures?.length}
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Ошибки</p>
|
||||
<ul class="space-y-2">
|
||||
{#each result.failures as failure}
|
||||
<li class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700">
|
||||
{failure.title || failure.id}: {failure.error}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if pluginId === 'superset-migration'}
|
||||
<div class="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Результат миграции</h3>
|
||||
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${statusColor(result.status)}`}>{result.status || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm text-slate-700">
|
||||
<p>Source: {result.source_environment || '-'}</p>
|
||||
<p>Target: {result.target_environment || '-'}</p>
|
||||
<p>Выбрано: {result.selected_dashboards ?? 0}</p>
|
||||
<p>Успешно: {result.migrated_dashboards?.length ?? 0}</p>
|
||||
<p>С ошибками: {result.failed_dashboards?.length ?? 0}</p>
|
||||
<p>Mappings: {result.mapping_count ?? 0}</p>
|
||||
</div>
|
||||
{#if result.failed_dashboards?.length}
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Ошибки миграции</p>
|
||||
<ul class="space-y-2">
|
||||
{#each result.failed_dashboards as failed}
|
||||
<li class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700">
|
||||
{failed.title || failed.id}: {failed.error}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<pre class="overflow-auto rounded bg-slate-50 p-3 text-xs text-slate-700">{JSON.stringify(result, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -149,19 +149,7 @@ export const api = {
|
||||
postApi,
|
||||
requestApi,
|
||||
getPlugins: () => fetchApi('/plugins'),
|
||||
getTasks: (options = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit != null) params.append('limit', String(options.limit));
|
||||
if (options.offset != null) params.append('offset', String(options.offset));
|
||||
if (options.status) params.append('status', options.status);
|
||||
if (options.task_type) params.append('task_type', options.task_type);
|
||||
if (options.completed_only != null) params.append('completed_only', String(Boolean(options.completed_only)));
|
||||
if (Array.isArray(options.plugin_id)) {
|
||||
options.plugin_id.forEach((pluginId) => params.append('plugin_id', pluginId));
|
||||
}
|
||||
const query = params.toString();
|
||||
return fetchApi(`/tasks${query ? `?${query}` : ''}`);
|
||||
},
|
||||
getTasks: () => fetchApi('/tasks'),
|
||||
getTask: (taskId) => fetchApi(`/tasks/${taskId}`),
|
||||
createTask: (pluginId, params) => postApi('/tasks', { plugin_id: pluginId, params }),
|
||||
|
||||
@@ -180,15 +168,14 @@ export const api = {
|
||||
getEnvironmentDatabases: (id) => fetchApi(`/environments/${id}/databases`),
|
||||
|
||||
// Dashboards
|
||||
getDashboards: (envId, options = {}) => {
|
||||
const params = new URLSearchParams({ env_id: envId });
|
||||
if (options.search) params.append('search', options.search);
|
||||
if (options.page) params.append('page', options.page);
|
||||
if (options.page_size) params.append('page_size', options.page_size);
|
||||
return fetchApi(`/dashboards?${params.toString()}`);
|
||||
},
|
||||
getDashboardDetail: (envId, dashboardId) => fetchApi(`/dashboards/${dashboardId}?env_id=${envId}`),
|
||||
getDatabaseMappings: (sourceEnvId, targetEnvId) => fetchApi(`/dashboards/db-mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
|
||||
getDashboards: (envId, options = {}) => {
|
||||
const params = new URLSearchParams({ env_id: envId });
|
||||
if (options.search) params.append('search', options.search);
|
||||
if (options.page) params.append('page', options.page);
|
||||
if (options.page_size) params.append('page_size', options.page_size);
|
||||
return fetchApi(`/dashboards?${params.toString()}`);
|
||||
},
|
||||
getDatabaseMappings: (sourceEnvId, targetEnvId) => fetchApi(`/dashboards/db-mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
|
||||
|
||||
// Datasets
|
||||
getDatasets: (envId, options = {}) => {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// [DEF:frontend.src.lib.api.assistant:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: assistant, api, client, chat, confirmation
|
||||
// @PURPOSE: API client wrapper for assistant chat, confirmation actions, and history retrieval.
|
||||
// @LAYER: Infra-API
|
||||
// @RELATION: DEPENDS_ON -> frontend.src.lib.api.api_module
|
||||
// @INVARIANT: All assistant requests must use requestApi wrapper (no native fetch).
|
||||
|
||||
import { requestApi } from '$lib/api.js';
|
||||
|
||||
// [DEF:sendAssistantMessage:Function]
|
||||
// @PURPOSE: Send a user message to assistant orchestrator endpoint.
|
||||
// @PRE: payload.message is a non-empty string.
|
||||
// @POST: Returns assistant response object with deterministic state.
|
||||
export function sendAssistantMessage(payload) {
|
||||
return requestApi('/assistant/messages', 'POST', payload);
|
||||
}
|
||||
// [/DEF:sendAssistantMessage:Function]
|
||||
|
||||
// [DEF:confirmAssistantOperation:Function]
|
||||
// @PURPOSE: Confirm a pending risky assistant operation.
|
||||
// @PRE: confirmationId references an existing pending token.
|
||||
// @POST: Returns execution response (started/success/failed).
|
||||
export function confirmAssistantOperation(confirmationId) {
|
||||
return requestApi(`/assistant/confirmations/${confirmationId}/confirm`, 'POST');
|
||||
}
|
||||
// [/DEF:confirmAssistantOperation:Function]
|
||||
|
||||
// [DEF:cancelAssistantOperation:Function]
|
||||
// @PURPOSE: Cancel a pending risky assistant operation.
|
||||
// @PRE: confirmationId references an existing pending token.
|
||||
// @POST: Operation is cancelled and cannot be executed by this token.
|
||||
export function cancelAssistantOperation(confirmationId) {
|
||||
return requestApi(`/assistant/confirmations/${confirmationId}/cancel`, 'POST');
|
||||
}
|
||||
// [/DEF:cancelAssistantOperation:Function]
|
||||
|
||||
// [DEF:getAssistantHistory:Function]
|
||||
// @PURPOSE: Retrieve paginated assistant conversation history.
|
||||
// @PRE: page/pageSize are positive integers.
|
||||
// @POST: Returns a paginated payload with history items.
|
||||
export function getAssistantHistory(page = 1, pageSize = 20, conversationId = null) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (conversationId) {
|
||||
params.append('conversation_id', conversationId);
|
||||
}
|
||||
return requestApi(`/assistant/history?${params.toString()}`, 'GET');
|
||||
}
|
||||
// [/DEF:getAssistantHistory:Function]
|
||||
// [/DEF:frontend.src.lib.api.assistant:Module]
|
||||
@@ -1,83 +0,0 @@
|
||||
// [DEF:frontend.src.lib.api.reports:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: frontend, api_client, reports, wrapper
|
||||
// @PURPOSE: Wrapper-based reports API client for list/detail retrieval without direct native fetch usage.
|
||||
// @LAYER: Infra
|
||||
// @RELATION: DEPENDS_ON -> [DEF:api_module]
|
||||
// @INVARIANT: Uses existing api wrapper methods and returns structured errors for UI-state mapping.
|
||||
|
||||
import { api } from '../api.js';
|
||||
|
||||
// [DEF:buildReportQueryString:Function]
|
||||
// @PURPOSE: Build query string for reports list endpoint from filter options.
|
||||
// @PRE: options is an object with optional report query fields.
|
||||
// @POST: Returns URL query string without leading '?'.
|
||||
export function buildReportQueryString(options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.page != null) params.append('page', String(options.page));
|
||||
if (options.page_size != null) params.append('page_size', String(options.page_size));
|
||||
|
||||
if (Array.isArray(options.task_types) && options.task_types.length > 0) {
|
||||
params.append('task_types', options.task_types.join(','));
|
||||
}
|
||||
if (Array.isArray(options.statuses) && options.statuses.length > 0) {
|
||||
params.append('statuses', options.statuses.join(','));
|
||||
}
|
||||
|
||||
if (options.time_from) params.append('time_from', options.time_from);
|
||||
if (options.time_to) params.append('time_to', options.time_to);
|
||||
if (options.search) params.append('search', options.search);
|
||||
if (options.sort_by) params.append('sort_by', options.sort_by);
|
||||
if (options.sort_order) params.append('sort_order', options.sort_order);
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
// [/DEF:buildReportQueryString:Function]
|
||||
|
||||
// [DEF:normalizeApiError:Function]
|
||||
// @PURPOSE: Convert unknown API exceptions into deterministic UI-consumable error objects.
|
||||
// @PRE: error may be Error/string/object.
|
||||
// @POST: Returns structured error object.
|
||||
export function normalizeApiError(error) {
|
||||
const message =
|
||||
(error && typeof error.message === 'string' && error.message) ||
|
||||
(typeof error === 'string' && error) ||
|
||||
'Failed to load reports';
|
||||
|
||||
return {
|
||||
message,
|
||||
code: 'REPORTS_API_ERROR',
|
||||
retryable: true
|
||||
};
|
||||
}
|
||||
// [/DEF:normalizeApiError:Function]
|
||||
|
||||
// [DEF:getReports:Function]
|
||||
// @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.
|
||||
export async function getReports(options = {}) {
|
||||
try {
|
||||
const query = buildReportQueryString(options);
|
||||
return await api.fetchApi(`/reports${query ? `?${query}` : ''}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// [/DEF:getReports:Function]
|
||||
|
||||
// [DEF:getReportDetail:Function]
|
||||
// @PURPOSE: Fetch one report detail by report_id.
|
||||
// @PRE: reportId is non-empty string; valid auth context.
|
||||
// @POST: Returns parsed detail payload or structured error object.
|
||||
export async function getReportDetail(reportId) {
|
||||
try {
|
||||
return await api.fetchApi(`/reports/${reportId}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// [/DEF:getReportDetail:Function]
|
||||
|
||||
// [/DEF:frontend.src.lib.api.reports:Module]
|
||||
@@ -1,342 +0,0 @@
|
||||
<!-- [DEF:AssistantChatPanel:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @PURPOSE: Slide-out assistant chat panel for natural language command execution and task tracking.
|
||||
* @LAYER: UI
|
||||
* @RELATION: BINDS_TO -> assistantChatStore
|
||||
* @RELATION: CALLS -> frontend.src.lib.api.assistant
|
||||
* @RELATION: DISPATCHES -> taskDrawerStore
|
||||
* @SEMANTICS: assistant-chat, confirmation, long-running-task, progress-tracking
|
||||
* @INVARIANT: User commands and assistant responses are appended in chronological order.
|
||||
* @INVARIANT: Risky operations are executed only through explicit confirm action.
|
||||
*
|
||||
* @UX_STATE: Closed -> Panel is hidden.
|
||||
* @UX_STATE: LoadingHistory -> Existing conversation history is loading.
|
||||
* @UX_STATE: Idle -> Input is available and no request in progress.
|
||||
* @UX_STATE: Sending -> Input locked while request is pending.
|
||||
* @UX_STATE: Error -> Failed action rendered as assistant failed message.
|
||||
* @UX_FEEDBACK: Started operation surfaces task_id and quick action to open task drawer.
|
||||
* @UX_RECOVERY: User can retry command or action from input and action buttons.
|
||||
* @UX_TEST: LoadingHistory -> {openPanel: true, expected: loading block visible}
|
||||
* @UX_TEST: Sending -> {sendMessage: "branch", expected: send button disabled}
|
||||
* @UX_TEST: NeedsConfirmation -> {click: confirm action, expected: started response with task_id}
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n';
|
||||
import Icon from '$lib/ui/Icon.svelte';
|
||||
import { openDrawerForTask } from '$lib/stores/taskDrawer.js';
|
||||
import {
|
||||
assistantChatStore,
|
||||
closeAssistantChat,
|
||||
setAssistantConversationId,
|
||||
} from '$lib/stores/assistantChat.js';
|
||||
import {
|
||||
sendAssistantMessage,
|
||||
confirmAssistantOperation,
|
||||
cancelAssistantOperation,
|
||||
getAssistantHistory,
|
||||
} from '$lib/api/assistant.js';
|
||||
|
||||
let input = '';
|
||||
let loading = false;
|
||||
let loadingHistory = false;
|
||||
let messages = [];
|
||||
let initialized = false;
|
||||
|
||||
$: isOpen = $assistantChatStore?.isOpen || false;
|
||||
$: conversationId = $assistantChatStore?.conversationId || null;
|
||||
|
||||
// [DEF:loadHistory:Function]
|
||||
/**
|
||||
* @PURPOSE: Load current conversation history when panel becomes visible.
|
||||
* @PRE: Panel is open and history request is not already running.
|
||||
* @POST: messages are populated from persisted history and conversation id is synchronized.
|
||||
* @SIDE_EFFECT: Performs API call to assistant history endpoint.
|
||||
*/
|
||||
async function loadHistory() {
|
||||
if (loadingHistory || !isOpen) return;
|
||||
loadingHistory = true;
|
||||
try {
|
||||
const history = await getAssistantHistory(1, 50, conversationId);
|
||||
messages = (history.items || []).map((msg) => ({
|
||||
...msg,
|
||||
actions: msg.actions || msg.metadata?.actions || [],
|
||||
}));
|
||||
if (history.conversation_id && history.conversation_id !== conversationId) {
|
||||
setAssistantConversationId(history.conversation_id);
|
||||
}
|
||||
initialized = true;
|
||||
console.log('[AssistantChatPanel][Coherence:OK] History loaded');
|
||||
} catch (err) {
|
||||
console.error('[AssistantChatPanel][Coherence:Failed] Failed to load history', err);
|
||||
} finally {
|
||||
loadingHistory = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:loadHistory:Function]
|
||||
|
||||
$: if (isOpen && !initialized) {
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
// [DEF:appendLocalUserMessage:Function]
|
||||
/**
|
||||
* @PURPOSE: Add optimistic local user message before backend response.
|
||||
* @PRE: text is non-empty command text.
|
||||
* @POST: user message appears at the end of messages list.
|
||||
*/
|
||||
function appendLocalUserMessage(text) {
|
||||
messages = [
|
||||
...messages,
|
||||
{
|
||||
message_id: `local-${Date.now()}`,
|
||||
role: 'user',
|
||||
text,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
// [/DEF:appendLocalUserMessage:Function]
|
||||
|
||||
// [DEF:appendAssistantResponse:Function]
|
||||
/**
|
||||
* @PURPOSE: Normalize and append assistant response payload to chat list.
|
||||
* @PRE: response follows assistant message response contract.
|
||||
* @POST: assistant message appended with state/task/actions metadata.
|
||||
*/
|
||||
function appendAssistantResponse(response) {
|
||||
messages = [
|
||||
...messages,
|
||||
{
|
||||
message_id: response.response_id,
|
||||
role: 'assistant',
|
||||
text: response.text,
|
||||
state: response.state,
|
||||
task_id: response.task_id || null,
|
||||
confirmation_id: response.confirmation_id || null,
|
||||
actions: response.actions || [],
|
||||
created_at: response.created_at,
|
||||
},
|
||||
];
|
||||
}
|
||||
// [/DEF:appendAssistantResponse:Function]
|
||||
|
||||
// [DEF:handleSend:Function]
|
||||
/**
|
||||
* @PURPOSE: Submit user command to assistant orchestration API.
|
||||
* @PRE: input contains a non-empty command and current request is not loading.
|
||||
* @POST: assistant response is rendered and conversation id is persisted in store.
|
||||
* @SIDE_EFFECT: Triggers backend command execution pipeline.
|
||||
*/
|
||||
async function handleSend() {
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
|
||||
appendLocalUserMessage(text);
|
||||
input = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const response = await sendAssistantMessage({
|
||||
conversation_id: conversationId,
|
||||
message: text,
|
||||
});
|
||||
|
||||
if (response.conversation_id) {
|
||||
setAssistantConversationId(response.conversation_id);
|
||||
}
|
||||
|
||||
appendAssistantResponse(response);
|
||||
} catch (err) {
|
||||
appendAssistantResponse({
|
||||
response_id: `error-${Date.now()}`,
|
||||
text: err.message || 'Assistant request failed',
|
||||
state: 'failed',
|
||||
created_at: new Date().toISOString(),
|
||||
actions: [],
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:handleSend:Function]
|
||||
|
||||
// [DEF:handleAction:Function]
|
||||
/**
|
||||
* @PURPOSE: Execute assistant action button behavior (open task/reports, confirm, cancel).
|
||||
* @PRE: action object is produced by assistant response contract.
|
||||
* @POST: UI navigation or follow-up assistant response is appended.
|
||||
* @SIDE_EFFECT: May navigate routes or call confirm/cancel API endpoints.
|
||||
*/
|
||||
async function handleAction(action, message) {
|
||||
try {
|
||||
if (action.type === 'open_task' && action.target) {
|
||||
openDrawerForTask(action.target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === 'open_reports') {
|
||||
goto('/reports');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === 'confirm' && message.confirmation_id) {
|
||||
const response = await confirmAssistantOperation(message.confirmation_id);
|
||||
appendAssistantResponse(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === 'cancel' && message.confirmation_id) {
|
||||
const response = await cancelAssistantOperation(message.confirmation_id);
|
||||
appendAssistantResponse(response);
|
||||
}
|
||||
} catch (err) {
|
||||
appendAssistantResponse({
|
||||
response_id: `action-error-${Date.now()}`,
|
||||
text: err.message || 'Action failed',
|
||||
state: 'failed',
|
||||
created_at: new Date().toISOString(),
|
||||
actions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
// [/DEF:handleAction:Function]
|
||||
|
||||
// [DEF:handleKeydown:Function]
|
||||
/**
|
||||
* @PURPOSE: Submit command by Enter while preserving multiline input with Shift+Enter.
|
||||
* @PRE: Keyboard event received from chat input.
|
||||
* @POST: handleSend is invoked when Enter is pressed without shift modifier.
|
||||
*/
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
// [/DEF:handleKeydown:Function]
|
||||
|
||||
// [DEF:stateClass:Function]
|
||||
/**
|
||||
* @PURPOSE: Map assistant state to visual badge style class.
|
||||
* @PRE: state is a nullable assistant state string.
|
||||
* @POST: Tailwind class string returned for badge rendering.
|
||||
*/
|
||||
function stateClass(state) {
|
||||
if (state === 'started') return 'bg-sky-100 text-sky-700 border-sky-200';
|
||||
if (state === 'success') return 'bg-emerald-100 text-emerald-700 border-emerald-200';
|
||||
if (state === 'needs_confirmation') return 'bg-amber-100 text-amber-700 border-amber-200';
|
||||
if (state === 'denied' || state === 'failed') return 'bg-rose-100 text-rose-700 border-rose-200';
|
||||
if (state === 'needs_clarification') return 'bg-violet-100 text-violet-700 border-violet-200';
|
||||
return 'bg-slate-100 text-slate-700 border-slate-200';
|
||||
}
|
||||
// [/DEF:stateClass:Function]
|
||||
|
||||
onMount(() => {
|
||||
initialized = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="fixed inset-0 z-[70] bg-slate-900/30" on:click={closeAssistantChat} aria-hidden="true"></div>
|
||||
|
||||
<aside class="fixed right-0 top-0 z-[71] h-full w-full max-w-md border-l border-slate-200 bg-white shadow-2xl">
|
||||
<div class="flex h-14 items-center justify-between border-b border-slate-200 px-4">
|
||||
<div class="flex items-center gap-2 text-slate-800">
|
||||
<Icon name="clipboard" size={18} />
|
||||
<h2 class="text-sm font-semibold">{$t.assistant?.title || 'AI Assistant'}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-md p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"
|
||||
on:click={closeAssistantChat}
|
||||
aria-label={$t.assistant?.close || 'Close assistant'}
|
||||
>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex h-[calc(100%-56px)] flex-col">
|
||||
<div class="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{#if loadingHistory}
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">{$t.assistant?.loading_history || 'Loading history...'}</div>
|
||||
{:else if messages.length === 0}
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{$t.assistant?.try_commands || 'Try commands:'}
|
||||
<div class="mt-2 space-y-1 text-xs">
|
||||
<div>• сделай ветку feature/new-dashboard для дашборда 42</div>
|
||||
<div>• запусти миграцию с dev на prod для дашборда 42</div>
|
||||
<div>• проверь статус задачи task-123</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as message (message.message_id)}
|
||||
<div class={message.role === 'user' ? 'ml-8' : 'mr-8'}>
|
||||
<div class="rounded-xl border p-3 {message.role === 'user' ? 'border-sky-200 bg-sky-50' : 'border-slate-200 bg-white'}">
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
{message.role === 'user' ? 'You' : 'Assistant'}
|
||||
</span>
|
||||
{#if message.state}
|
||||
<span class="rounded-md border px-2 py-0.5 text-[10px] font-medium {stateClass(message.state)}">
|
||||
{$t.assistant?.states?.[message.state] || message.state}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="whitespace-pre-wrap text-sm text-slate-800">{message.text}</div>
|
||||
|
||||
{#if message.task_id}
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="rounded border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs text-slate-700">task_id: {message.task_id}</span>
|
||||
<button
|
||||
class="text-xs font-medium text-sky-700 hover:text-sky-900"
|
||||
on:click={() => openDrawerForTask(message.task_id)}
|
||||
>
|
||||
{$t.assistant?.open_task_drawer || 'Open Task Drawer'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if message.actions?.length}
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#each message.actions as action}
|
||||
<button
|
||||
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
on:click={() => handleAction(action, message)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 p-3">
|
||||
<div class="flex items-end gap-2">
|
||||
<textarea
|
||||
bind:value={input}
|
||||
rows="2"
|
||||
placeholder={$t.assistant?.input_placeholder || 'Type a command...'}
|
||||
class="min-h-[52px] w-full resize-y rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
|
||||
on:keydown={handleKeydown}
|
||||
></textarea>
|
||||
<button
|
||||
class="rounded-lg bg-sky-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
on:click={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
{loading ? '...' : ($t.assistant?.send || 'Send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
<!-- [/DEF:AssistantChatPanel:Component] -->
|
||||
@@ -1,87 +0,0 @@
|
||||
// [DEF:frontend.src.lib.components.assistant.__tests__.assistant_chat_integration:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: assistant, integration-test, ux-contract, i18n
|
||||
// @PURPOSE: Contract-level integration checks for assistant chat panel implementation and localization wiring.
|
||||
// @LAYER: UI Tests
|
||||
// @RELATION: VERIFIES -> frontend/src/lib/components/assistant/AssistantChatPanel.svelte
|
||||
// @INVARIANT: Critical assistant UX states and action hooks remain present in component source.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const COMPONENT_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/components/assistant/AssistantChatPanel.svelte',
|
||||
);
|
||||
const EN_LOCALE_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/i18n/locales/en.json',
|
||||
);
|
||||
const RU_LOCALE_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/i18n/locales/ru.json',
|
||||
);
|
||||
|
||||
// [DEF:readJson:Function]
|
||||
// @PURPOSE: Read and parse JSON fixture file from disk.
|
||||
// @PRE: filePath points to existing UTF-8 JSON file.
|
||||
// @POST: Returns parsed object representation.
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
// [/DEF:readJson:Function]
|
||||
|
||||
// [DEF:assistant_chat_contract_tests:Function]
|
||||
// @PURPOSE: Validate assistant chat component contract and locale integration without DOM runtime dependency.
|
||||
// @PRE: Component and locale files exist in expected paths.
|
||||
// @POST: Contract checks guarantee assistant UI anchors and i18n wiring remain intact.
|
||||
describe('AssistantChatPanel integration contract', () => {
|
||||
it('contains semantic anchors and UX contract tags', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('<!-- [DEF' + ':AssistantChatPanel:Component] -->');
|
||||
expect(source).toContain('@TIER: CRITICAL');
|
||||
expect(source).toContain('@UX_STATE: LoadingHistory');
|
||||
expect(source).toContain('@UX_STATE: Sending');
|
||||
expect(source).toContain('@UX_STATE: Error');
|
||||
expect(source).toContain('@UX_FEEDBACK: Started operation surfaces task_id');
|
||||
expect(source).toContain('@UX_RECOVERY: User can retry command');
|
||||
expect(source).toContain('<!-- [/DEF' + ':AssistantChatPanel:Component] -->');
|
||||
});
|
||||
|
||||
it('keeps confirmation/task-tracking action hooks in place', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain("if (action.type === 'confirm' && message.confirmation_id)");
|
||||
expect(source).toContain("if (action.type === 'cancel' && message.confirmation_id)");
|
||||
expect(source).toContain("if (action.type === 'open_task' && action.target)");
|
||||
expect(source).toContain('openDrawerForTask(action.target)');
|
||||
expect(source).toContain("goto('/reports')");
|
||||
});
|
||||
|
||||
it('uses i18n bindings for assistant UI labels', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('$t.assistant?.title');
|
||||
expect(source).toContain('$t.assistant?.input_placeholder');
|
||||
expect(source).toContain('$t.assistant?.send');
|
||||
expect(source).toContain('$t.assistant?.states?.[message.state]');
|
||||
expect(source).toContain('$t.assistant?.open_task_drawer');
|
||||
});
|
||||
|
||||
it('provides assistant locale keys in both en and ru dictionaries', () => {
|
||||
const en = readJson(EN_LOCALE_PATH);
|
||||
const ru = readJson(RU_LOCALE_PATH);
|
||||
|
||||
expect(en.assistant.title).toBeTruthy();
|
||||
expect(en.assistant.send).toBeTruthy();
|
||||
expect(en.assistant.states.needs_confirmation).toBeTruthy();
|
||||
|
||||
expect(ru.assistant.title).toBeTruthy();
|
||||
expect(ru.assistant.send).toBeTruthy();
|
||||
expect(ru.assistant.states.needs_confirmation).toBeTruthy();
|
||||
});
|
||||
});
|
||||
// [/DEF:assistant_chat_contract_tests:Function]
|
||||
// [/DEF:frontend.src.lib.components.assistant.__tests__.assistant_chat_integration:Module]
|
||||
@@ -1,50 +0,0 @@
|
||||
// [DEF:frontend.src.lib.components.assistant.__tests__.assistant_confirmation_integration:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: assistant, confirmation, integration-test, ux
|
||||
// @PURPOSE: Validate confirm/cancel UX contract bindings in assistant chat panel source.
|
||||
// @LAYER: UI Tests
|
||||
// @RELATION: VERIFIES -> frontend/src/lib/components/assistant/AssistantChatPanel.svelte
|
||||
// @INVARIANT: Confirm/cancel action handling must remain explicit and confirmation-id bound.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const COMPONENT_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/components/assistant/AssistantChatPanel.svelte',
|
||||
);
|
||||
|
||||
// [DEF:assistant_confirmation_contract_tests:Function]
|
||||
// @PURPOSE: Assert that confirmation UX flow and API bindings are preserved in chat panel.
|
||||
// @PRE: Assistant panel source file exists and is readable.
|
||||
// @POST: Test guarantees explicit confirm/cancel guards and failed-action recovery path.
|
||||
describe('AssistantChatPanel confirmation integration contract', () => {
|
||||
it('contains confirmation action guards with confirmation_id checks', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain("if (action.type === 'confirm' && message.confirmation_id)");
|
||||
expect(source).toContain("if (action.type === 'cancel' && message.confirmation_id)");
|
||||
expect(source).toContain('confirmAssistantOperation(message.confirmation_id)');
|
||||
expect(source).toContain('cancelAssistantOperation(message.confirmation_id)');
|
||||
});
|
||||
|
||||
it('renders action buttons from assistant response payload', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('{#if message.actions?.length}');
|
||||
expect(source).toContain('{#each message.actions as action}');
|
||||
expect(source).toContain('{action.label}');
|
||||
expect(source).toContain('on:click={() => handleAction(action, message)}');
|
||||
});
|
||||
|
||||
it('keeps failed-action recovery response path', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain("response_id: `action-error-${Date.now()}`");
|
||||
expect(source).toContain("state: 'failed'");
|
||||
expect(source).toContain("text: err.message || 'Action failed'");
|
||||
});
|
||||
});
|
||||
// [/DEF:assistant_confirmation_contract_tests:Function]
|
||||
// [/DEF:frontend.src.lib.components.assistant.__tests__.assistant_confirmation_integration:Module]
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
import { page } from "$app/stores";
|
||||
import { t, _ } from "$lib/i18n";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
|
||||
let { maxVisible = 3 } = $props();
|
||||
|
||||
@@ -83,103 +82,30 @@
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getCrumbMeta(item) {
|
||||
if (item.path === "/") {
|
||||
return {
|
||||
icon: "home",
|
||||
tone: "from-sky-100 to-cyan-100 text-sky-700 ring-sky-200",
|
||||
};
|
||||
}
|
||||
|
||||
const segment = item.path.split("/").filter(Boolean).at(-1) || "";
|
||||
|
||||
const map = {
|
||||
dashboards: {
|
||||
icon: "dashboard",
|
||||
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
|
||||
},
|
||||
datasets: {
|
||||
icon: "database",
|
||||
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
|
||||
},
|
||||
storage: {
|
||||
icon: "storage",
|
||||
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
|
||||
},
|
||||
reports: {
|
||||
icon: "reports",
|
||||
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
|
||||
},
|
||||
admin: {
|
||||
icon: "admin",
|
||||
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
|
||||
},
|
||||
settings: {
|
||||
icon: "settings",
|
||||
tone: "from-slate-100 to-slate-200 text-slate-700 ring-slate-200",
|
||||
},
|
||||
git: {
|
||||
icon: "storage",
|
||||
tone: "from-orange-100 to-orange-200 text-orange-700 ring-orange-200",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
map[segment] || {
|
||||
icon: "layers",
|
||||
tone: "from-slate-100 to-slate-200 text-slate-600 ring-slate-200",
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="mx-4 md:mx-6"
|
||||
class="flex items-center space-x-2 text-sm text-gray-600"
|
||||
aria-label="Breadcrumb navigation"
|
||||
>
|
||||
<div class="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-slate-200/80 bg-white/85 px-2 py-1.5 shadow-sm backdrop-blur">
|
||||
{#each breadcrumbItems as item, index}
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if item.isEllipsis}
|
||||
<span class="px-2 py-1 text-xs font-semibold tracking-wide text-slate-400"
|
||||
>...</span
|
||||
>
|
||||
{:else}
|
||||
{@const meta = getCrumbMeta(item)}
|
||||
{#if item.isLast}
|
||||
<span
|
||||
class="inline-flex min-w-0 items-center gap-2 rounded-lg bg-slate-900 px-2.5 py-1.5 text-sm font-medium text-white"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-white/10"
|
||||
>
|
||||
<Icon name={meta.icon} size={12} strokeWidth={2.1} />
|
||||
</span>
|
||||
<span class="truncate">{item.label}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<a
|
||||
href={item.path}
|
||||
class="inline-flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm text-slate-700 ring-1 ring-transparent transition-all hover:bg-slate-50 hover:ring-slate-200"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-gradient-to-br ring-1 {meta.tone}"
|
||||
>
|
||||
<Icon name={meta.icon} size={12} strokeWidth={2.1} />
|
||||
</span>
|
||||
<span class="truncate">{item.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if index < breadcrumbItems.length - 1}
|
||||
<span class="text-slate-300">
|
||||
<Icon name="chevronRight" size={14} strokeWidth={2.1} />
|
||||
</span>
|
||||
{#each breadcrumbItems as item, index}
|
||||
<div class="flex items-center">
|
||||
{#if item.isEllipsis}
|
||||
<span class="text-gray-400">...</span>
|
||||
{:else if item.isLast}
|
||||
<span class="text-gray-900 font-medium">{item.label}</span>
|
||||
{:else}
|
||||
<a
|
||||
href={item.path}
|
||||
class="hover:text-primary hover:underline cursor-pointer transition-colors"
|
||||
>{item.label}</a
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if index < breadcrumbItems.length - 1}
|
||||
<span class="text-gray-400">/</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- [/DEF:Breadcrumbs:Component] -->
|
||||
|
||||
@@ -24,68 +24,52 @@
|
||||
} from "$lib/stores/sidebar.js";
|
||||
import { t } from "$lib/i18n";
|
||||
import { browser } from "$app/environment";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
|
||||
function buildCategories() {
|
||||
return [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: $t.nav?.dashboards || "DASHBOARDS",
|
||||
icon: "dashboard",
|
||||
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
|
||||
path: "/dashboards",
|
||||
subItems: [
|
||||
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "datasets",
|
||||
label: $t.nav?.datasets || "DATASETS",
|
||||
icon: "database",
|
||||
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
|
||||
path: "/datasets",
|
||||
subItems: [
|
||||
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: $t.nav?.storage || "STORAGE",
|
||||
icon: "storage",
|
||||
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
|
||||
path: "/storage",
|
||||
subItems: [
|
||||
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
|
||||
{
|
||||
label: $t.nav?.repositories || "Repositories",
|
||||
path: "/storage/repos",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: $t.nav?.reports || "REPORTS",
|
||||
icon: "reports",
|
||||
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
|
||||
path: "/reports",
|
||||
subItems: [{ label: $t.nav?.reports || "Reports", path: "/reports" }],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin || "ADMIN",
|
||||
icon: "admin",
|
||||
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
|
||||
{ label: $t.nav?.admin_roles || "Roles", path: "/admin/roles" },
|
||||
{ label: $t.nav?.settings || "Settings", path: "/settings" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let categories = buildCategories();
|
||||
// Sidebar categories with sub-items matching Superset-style navigation
|
||||
let categories = [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: $t.nav?.dashboards || "DASHBOARDS",
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
|
||||
path: "/dashboards",
|
||||
subItems: [
|
||||
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "datasets",
|
||||
label: $t.nav?.datasets || "DATASETS",
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
|
||||
path: "/datasets",
|
||||
subItems: [
|
||||
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: $t.nav?.storage || "STORAGE",
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
|
||||
path: "/storage",
|
||||
subItems: [
|
||||
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
|
||||
{
|
||||
label: $t.nav?.repositories || "Repositories",
|
||||
path: "/storage/repos",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin || "ADMIN",
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
|
||||
{ label: $t.nav?.admin_roles || "Roles", path: "/admin/roles" },
|
||||
{ label: $t.nav?.settings || "Settings", path: "/settings" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let isExpanded = true;
|
||||
let activeCategory = "dashboards";
|
||||
@@ -102,7 +86,50 @@
|
||||
}
|
||||
|
||||
// Reactive categories to update translations
|
||||
$: categories = buildCategories();
|
||||
$: categories = [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: $t.nav?.dashboards || "DASHBOARDS",
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
|
||||
path: "/dashboards",
|
||||
subItems: [
|
||||
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "datasets",
|
||||
label: $t.nav?.datasets || "DATASETS",
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
|
||||
path: "/datasets",
|
||||
subItems: [
|
||||
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: $t.nav?.storage || "STORAGE",
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
|
||||
path: "/storage",
|
||||
subItems: [
|
||||
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
|
||||
{
|
||||
label: $t.nav?.repositories || "Repositories",
|
||||
path: "/storage/repos",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin || "ADMIN",
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
|
||||
{ label: $t.nav?.admin_roles || "Roles", path: "/admin/roles" },
|
||||
{ label: $t.nav?.settings || "Settings", path: "/settings" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Update active item when page changes
|
||||
$: if ($page && $page.url.pathname !== activeItem) {
|
||||
@@ -184,7 +211,7 @@
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div
|
||||
class="fixed left-0 top-0 z-30 flex h-screen flex-col border-r border-slate-200 bg-white shadow-sm transition-[width] duration-200 ease-in-out
|
||||
class="bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30 transition-[width] duration-200 ease-in-out
|
||||
{isExpanded ? 'w-sidebar' : 'w-sidebar-collapsed'}
|
||||
{isMobileOpen
|
||||
? 'translate-x-0 w-sidebar'
|
||||
@@ -192,17 +219,12 @@
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center border-b border-slate-200 p-4 {isExpanded
|
||||
class="flex items-center p-4 border-b border-gray-200 {isExpanded
|
||||
? 'justify-between'
|
||||
: 'justify-center'}"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<span class="font-semibold text-gray-800 flex items-center gap-2">
|
||||
<span class="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-slate-100 to-slate-200 text-slate-700 ring-1 ring-slate-200">
|
||||
<Icon name="layers" size={14} />
|
||||
</span>
|
||||
Menu
|
||||
</span>
|
||||
<span class="font-semibold text-gray-800">Menu</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">M</span>
|
||||
{/if}
|
||||
@@ -214,7 +236,7 @@
|
||||
<div>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between px-4 py-3 transition-colors hover:bg-slate-100
|
||||
class="flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100
|
||||
{activeCategory === category.id
|
||||
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
|
||||
: ''}"
|
||||
@@ -228,9 +250,16 @@
|
||||
aria-expanded={expandedCategories.has(category.id)}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}">
|
||||
<Icon name={category.icon} size={16} strokeWidth={2} />
|
||||
</span>
|
||||
<svg
|
||||
class="w-5 h-5 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d={category.icon} />
|
||||
</svg>
|
||||
{#if isExpanded}
|
||||
<span class="ml-3 text-sm font-medium truncate"
|
||||
>{category.label}</span
|
||||
@@ -238,15 +267,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
size={16}
|
||||
<svg
|
||||
class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
|
||||
category.id,
|
||||
)
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -282,9 +318,18 @@
|
||||
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}
|
||||
>
|
||||
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-slate-100 text-slate-600">
|
||||
<Icon name="chevronLeft" size={14} />
|
||||
</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="mr-2"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
@@ -295,7 +340,17 @@
|
||||
on:click={handleToggleClick}
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
<Icon name="chevronRight" size={16} />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
<span class="ml-2">Expand</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @TIER: CRITICAL
|
||||
* @PURPOSE: Global task drawer for monitoring background operations
|
||||
* @LAYER: UI
|
||||
* @RELATION: BINDS_TO -> taskDrawerStore, assistantChatStore, WebSocket
|
||||
* @RELATION: BINDS_TO -> taskDrawerStore, WebSocket
|
||||
* @SEMANTICS: TaskLogViewer
|
||||
* @INVARIANT: Drawer shows logs for active task or remains closed
|
||||
*
|
||||
@@ -20,12 +20,10 @@
|
||||
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { taskDrawerStore, closeDrawer } from "$lib/stores/taskDrawer.js";
|
||||
import { assistantChatStore } from "$lib/stores/assistantChat.js";
|
||||
import TaskLogViewer from "../../../components/TaskLogViewer.svelte";
|
||||
import PasswordPrompt from "../../../components/PasswordPrompt.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { api } from "$lib/api.js";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
|
||||
let isOpen = false;
|
||||
let activeTaskId = null;
|
||||
@@ -34,15 +32,12 @@
|
||||
let taskStatus = null;
|
||||
let recentTasks = [];
|
||||
let loadingTasks = false;
|
||||
let isAssistantOpen = false;
|
||||
|
||||
// Subscribe to task drawer store
|
||||
$: if ($taskDrawerStore) {
|
||||
isOpen = $taskDrawerStore.isOpen;
|
||||
activeTaskId = $taskDrawerStore.activeTaskId;
|
||||
}
|
||||
$: isAssistantOpen = Boolean($assistantChatStore?.isOpen);
|
||||
$: assistantOffset = isAssistantOpen ? "min(100vw, 28rem)" : "0px";
|
||||
|
||||
// Derive short task ID for display
|
||||
$: shortTaskId = activeTaskId
|
||||
@@ -59,11 +54,6 @@
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
function goToReportsPage() {
|
||||
closeDrawer();
|
||||
window.location.href = "/reports";
|
||||
}
|
||||
|
||||
// Handle overlay click
|
||||
function handleOverlayClick(event) {
|
||||
if (event.target === event.currentTarget) {
|
||||
@@ -195,8 +185,7 @@
|
||||
<!-- Drawer Overlay -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-[69] bg-black/35 backdrop-blur-sm"
|
||||
style={`right: ${assistantOffset};`}
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||
on:click={handleOverlayClick}
|
||||
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
role="button"
|
||||
@@ -205,18 +194,19 @@
|
||||
>
|
||||
<!-- Drawer Panel -->
|
||||
<div
|
||||
class="fixed top-0 z-[72] flex h-full w-full max-w-[560px] flex-col border-l border-slate-200 bg-white shadow-[-8px_0_30px_rgba(15,23,42,0.15)] transition-[right] duration-300 ease-out"
|
||||
style={`right: ${assistantOffset};`}
|
||||
class="fixed right-0 top-0 h-full w-full max-w-[560px] bg-slate-900 shadow-[-8px_0_30px_rgba(0,0,0,0.3)] flex flex-col z-50 transition-transform duration-300 ease-out"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Task drawer"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-5 py-3.5">
|
||||
<div class="flex items-center justify-between px-5 py-3.5 border-b border-slate-800 bg-slate-900">
|
||||
<div class="flex items-center gap-2.5">
|
||||
{#if !activeTaskId && recentTasks.length > 0}
|
||||
<span class="flex items-center justify-center p-1.5 mr-1 text-cyan-400">
|
||||
<Icon name="list" size={16} strokeWidth={2} />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
||||
</svg>
|
||||
</span>
|
||||
{:else if activeTaskId}
|
||||
<button
|
||||
@@ -224,10 +214,20 @@
|
||||
on:click={goBackToList}
|
||||
aria-label="Back to task list"
|
||||
>
|
||||
<Icon name="back" size={16} strokeWidth={2} />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h2 class="text-sm font-semibold tracking-tight text-slate-900">
|
||||
<h2 class="text-sm font-semibold text-slate-100 tracking-tight">
|
||||
{activeTaskId ? ($t.tasks?.details_logs || 'Task Details & Logs') : 'Recent Tasks'}
|
||||
</h2>
|
||||
{#if shortTaskId}
|
||||
@@ -239,21 +239,23 @@
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
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}
|
||||
<button
|
||||
class="p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
||||
on:click={handleClose}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{$t.nav?.reports || "Reports"}
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
||||
on:click={handleClose}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<Icon name="close" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -267,7 +269,7 @@
|
||||
/>
|
||||
{:else if loadingTasks}
|
||||
<div class="flex flex-col items-center justify-center p-12 text-slate-500">
|
||||
<div class="mb-4 h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-blue-500"></div>
|
||||
<div class="w-8 h-8 border-2 border-slate-200 border-t-blue-500 rounded-full animate-spin mb-4"></div>
|
||||
<p>Loading tasks...</p>
|
||||
</div>
|
||||
{:else if recentTasks.length > 0}
|
||||
@@ -286,12 +288,18 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center h-full text-slate-500">
|
||||
<Icon
|
||||
name="clipboard"
|
||||
size={48}
|
||||
strokeWidth={1.6}
|
||||
className="mb-3 text-slate-700"
|
||||
/>
|
||||
<svg
|
||||
class="w-12 h-12 mb-3 text-slate-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<p>{$t.tasks?.select_task || 'No recent tasks'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -309,3 +317,5 @@
|
||||
{/if}
|
||||
|
||||
<!-- [/DEF:TaskDrawer:Component] -->
|
||||
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
* @UX_STATE: SearchFocused -> Search input expands
|
||||
* @UX_FEEDBACK: Activity badge shows count of running tasks
|
||||
* @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}
|
||||
*/
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
@@ -27,8 +25,6 @@
|
||||
import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.js";
|
||||
import { t } from "$lib/i18n";
|
||||
import { auth } from "$lib/auth/store.js";
|
||||
import { toggleAssistantChat } from "$lib/stores/assistantChat.js";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -67,10 +63,6 @@
|
||||
dispatch("activityClick");
|
||||
}
|
||||
|
||||
function handleAssistantClick() {
|
||||
toggleAssistantChat();
|
||||
}
|
||||
|
||||
function handleSearchFocus() {
|
||||
isSearchFocused = true;
|
||||
}
|
||||
@@ -96,28 +88,45 @@
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="fixed left-0 right-0 top-0 z-40 flex h-16 items-center justify-between border-b border-slate-200 bg-white px-4 shadow-sm
|
||||
class="bg-white border-b border-gray-200 fixed top-0 right-0 left-0 h-16 flex items-center justify-between px-4 z-40
|
||||
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
|
||||
>
|
||||
<!-- Left section: Hamburger (mobile) + Logo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Hamburger Menu (mobile only) -->
|
||||
<button
|
||||
class="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 md:hidden"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden"
|
||||
on:click={handleHamburgerClick}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<Icon name="menu" size={22} />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Logo/Brand -->
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center text-xl font-bold text-slate-800 transition-colors hover:text-primary"
|
||||
class="flex items-center text-xl font-bold text-gray-800 hover:text-primary transition-colors"
|
||||
>
|
||||
<span class="mr-2 inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500 via-cyan-500 to-indigo-600 text-white shadow-sm">
|
||||
<Icon name="layers" size={18} strokeWidth={2.1} />
|
||||
</span>
|
||||
<svg
|
||||
class="w-8 h-8 mr-2 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
<span>Superset Tools</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -135,20 +144,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Nav Actions -->
|
||||
<div class="flex items-center gap-3 md:gap-4">
|
||||
<!-- Assistant -->
|
||||
<button
|
||||
class="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
|
||||
on:click={handleAssistantClick}
|
||||
aria-label={$t.assistant?.open || "Open assistant"}
|
||||
title={$t.assistant?.title || "AI Assistant"}
|
||||
>
|
||||
<Icon name="clipboard" size={22} />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Activity Indicator -->
|
||||
<div
|
||||
class="relative cursor-pointer rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
|
||||
class="relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
on:click={handleActivityClick}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && handleActivityClick()}
|
||||
@@ -156,7 +155,18 @@
|
||||
tabindex="0"
|
||||
aria-label="Activity"
|
||||
>
|
||||
<Icon name="activity" size={22} />
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"
|
||||
/>
|
||||
</svg>
|
||||
{#if activeCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 bg-destructive text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// [DEF:__tests__/test_breadcrumbs:Module]
|
||||
// @TIER: STANDARD
|
||||
// @PURPOSE: Contract-focused unit tests for Breadcrumbs.svelte logic and UX annotations
|
||||
// @LAYER: UI
|
||||
// @RELATION: VERIFIES -> frontend/src/lib/components/layout/Breadcrumbs.svelte
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const COMPONENT_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/components/layout/Breadcrumbs.svelte'
|
||||
);
|
||||
|
||||
function getBreadcrumbs(pathname, maxVisible = 3) {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const allItems = [{ label: 'Home', path: '/' }];
|
||||
|
||||
let currentPath = '';
|
||||
segments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`;
|
||||
const label = formatBreadcrumbLabel(segment);
|
||||
allItems.push({
|
||||
label,
|
||||
path: currentPath,
|
||||
isLast: index === segments.length - 1
|
||||
});
|
||||
});
|
||||
|
||||
if (allItems.length > maxVisible) {
|
||||
const firstItem = allItems[0];
|
||||
const itemsToShow = [];
|
||||
itemsToShow.push(firstItem);
|
||||
itemsToShow.push({ isEllipsis: true });
|
||||
|
||||
const startFromIndex = allItems.length - (maxVisible - 1);
|
||||
for (let i = startFromIndex; i < allItems.length; i++) {
|
||||
itemsToShow.push(allItems[i]);
|
||||
}
|
||||
return itemsToShow;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
function formatBreadcrumbLabel(segment) {
|
||||
const specialCases = {
|
||||
dashboards: 'Dashboards',
|
||||
datasets: 'Datasets',
|
||||
storage: 'Storage',
|
||||
admin: 'Admin',
|
||||
settings: 'Settings',
|
||||
git: 'Git'
|
||||
};
|
||||
|
||||
if (specialCases[segment]) {
|
||||
return specialCases[segment];
|
||||
}
|
||||
|
||||
return segment
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
describe('Breadcrumbs Component Contract & Logic', () => {
|
||||
it('contains required UX tags and semantic header for STANDARD module', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('@TIER: STANDARD');
|
||||
expect(source).toContain('@UX_STATE: Idle');
|
||||
expect(source).toContain('@UX_FEEDBACK');
|
||||
expect(source).toContain('@UX_RECOVERY');
|
||||
expect(source).toContain('@RELATION: DEPENDS_ON -> page store');
|
||||
});
|
||||
|
||||
it('returns Home for root path (Short-Path UX state)', () => {
|
||||
const result = getBreadcrumbs('/', 3);
|
||||
|
||||
expect(result).toEqual([{ label: 'Home', path: '/' }]);
|
||||
});
|
||||
|
||||
it('maps known segments to expected labels', () => {
|
||||
expect(formatBreadcrumbLabel('dashboards')).toBe('Dashboards');
|
||||
expect(formatBreadcrumbLabel('datasets')).toBe('Datasets');
|
||||
expect(formatBreadcrumbLabel('settings')).toBe('Settings');
|
||||
});
|
||||
|
||||
it('formats unknown kebab-case segment to title case', () => {
|
||||
expect(formatBreadcrumbLabel('data-quality-rules')).toBe('Data Quality Rules');
|
||||
});
|
||||
|
||||
it('truncates long paths with ellipsis and keeps tail segments', () => {
|
||||
const result = getBreadcrumbs('/dashboards/segment-a/segment-b/segment-c', 3);
|
||||
|
||||
expect(result[0]).toEqual({ label: 'Home', path: '/' });
|
||||
expect(result[1]).toEqual({ isEllipsis: true });
|
||||
const lastItem = result[result.length - 1];
|
||||
expect('label' in lastItem && lastItem.label).toBe('Segment C');
|
||||
expect(result.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:__tests__/test_breadcrumbs:Module]
|
||||
@@ -1,71 +0,0 @@
|
||||
<!-- [DEF:ReportCard:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, card, type-profile, accessibility, fallback
|
||||
* @PURPOSE: Render one report with explicit textual type label and profile-driven visual variant.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/components/reports/reportTypeProfiles.js
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Unknown task type always uses fallback profile.
|
||||
*
|
||||
* @UX_STATE: Ready -> Card displays summary/status/type.
|
||||
* @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.
|
||||
*/
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getReportTypeProfile } from './reportTypeProfiles.js';
|
||||
|
||||
let { report, selected = false } = $props();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const profile = $derived(getReportTypeProfile(report?.task_type));
|
||||
const profileLabel = $derived(typeof profile?.label === 'function' ? profile.label() : profile?.label);
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (status === 'success') return 'bg-green-100 text-green-700 ring-1 ring-green-200';
|
||||
if (status === 'failed') return 'bg-red-100 text-red-700 ring-1 ring-red-200';
|
||||
if (status === 'in_progress') return 'bg-blue-100 text-blue-700 ring-1 ring-blue-200';
|
||||
if (status === 'partial') return 'bg-amber-100 text-amber-700 ring-1 ring-amber-200';
|
||||
return 'bg-slate-100 text-slate-700 ring-1 ring-slate-200';
|
||||
}
|
||||
|
||||
function getStatusLabel(status) {
|
||||
if (status === 'success') return $t.reports?.status_success || 'Success';
|
||||
if (status === 'failed') return $t.reports?.status_failed || 'Failed';
|
||||
if (status === 'in_progress') return $t.reports?.status_in_progress || 'In progress';
|
||||
if (status === 'partial') return $t.reports?.status_partial || 'Partial';
|
||||
return status || ($t.reports?.not_provided || 'Not provided');
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return $t.reports?.not_provided || 'Not provided';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided || 'Not provided';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
dispatch('select', { report });
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="w-full rounded-xl border p-4 text-left shadow-sm transition hover:border-slate-300 hover:bg-slate-50 hover:shadow {selected ? 'border-blue-400 bg-blue-50' : 'border-slate-200 bg-white'}"
|
||||
on:click={onSelect}
|
||||
aria-label={`Report ${report?.report_id || ''} type ${profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}`}
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="rounded px-2 py-0.5 text-xs font-semibold {profile?.variant || 'bg-slate-100 text-slate-700'}">
|
||||
{profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}
|
||||
</span>
|
||||
<span class="rounded px-2 py-0.5 text-xs font-semibold {getStatusClass(report?.status)}">
|
||||
{getStatusLabel(report?.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-800">{report?.summary || ($t.reports?.not_provided || 'Not provided')}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{formatDate(report?.updated_at)}</p>
|
||||
</button>
|
||||
|
||||
<!-- [/DEF:ReportCard:Component] -->
|
||||
@@ -1,66 +0,0 @@
|
||||
<!-- [DEF:ReportDetailPanel:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, detail, diagnostics, next-actions, placeholders
|
||||
* @PURPOSE: Display detailed report context with diagnostics and actionable recovery guidance.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Failed/partial reports surface actionable hints when available.
|
||||
*
|
||||
* @UX_STATE: Ready -> Report detail content visible.
|
||||
* @UX_RECOVERY: Failed/partial report shows next actions and placeholder-safe diagnostics.
|
||||
*/
|
||||
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { detail = null } = $props();
|
||||
|
||||
function notProvided(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return $t.reports?.not_provided || 'Not provided';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return $t.reports?.not_provided || 'Not provided';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided || 'Not provided';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<h3 class="mb-3 text-sm font-semibold text-slate-700">{$t.reports?.view_details || 'View details'}</h3>
|
||||
|
||||
{#if !detail || !detail.report}
|
||||
<p class="text-sm text-slate-500">{$t.reports?.not_provided || 'Not provided'}</p>
|
||||
{:else}
|
||||
<div class="space-y-2 text-sm text-slate-700">
|
||||
<p><span class="text-slate-500">ID:</span> {notProvided(detail.report.report_id)}</p>
|
||||
<p><span class="text-slate-500">Type:</span> {notProvided(detail.report.task_type)}</p>
|
||||
<p><span class="text-slate-500">Status:</span> {notProvided(detail.report.status)}</p>
|
||||
<p><span class="text-slate-500">Summary:</span> {notProvided(detail.report.summary)}</p>
|
||||
<p><span class="text-slate-500">Updated:</span> {formatDate(detail.report.updated_at)}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">{$t.reports?.diagnostics || 'Diagnostics'}</p>
|
||||
<pre class="max-h-48 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-2 text-xs text-slate-700">{JSON.stringify(detail.diagnostics || { note: $t.reports?.not_provided || 'Not provided' }, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
{#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)}
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">{$t.reports?.next_actions || 'Next actions'}</p>
|
||||
<ul class="list-disc space-y-1 pl-5 text-sm text-slate-700">
|
||||
{#each (detail.next_actions && detail.next_actions.length > 0 ? detail.next_actions : detail.report.error_context.next_actions) as action}
|
||||
<li>{action}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:ReportDetailPanel:Component] -->
|
||||
@@ -1,37 +0,0 @@
|
||||
<!-- [DEF:ReportsList:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, list, card, unified, mixed-types
|
||||
* @PURPOSE: Render unified list of normalized reports with canonical minimum fields.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Every rendered row shows task_type label, status, summary, and updated_at.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
|
||||
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 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each reports as report (report.report_id)}
|
||||
<ReportCard
|
||||
{report}
|
||||
selected={selectedReportId === report.report_id}
|
||||
on:select={handleSelect}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:ReportsList:Component] -->
|
||||
@@ -1,90 +0,0 @@
|
||||
// [DEF:reports.fixtures:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: reports, fixtures, test-data
|
||||
// @PURPOSE: Shared frontend fixtures for unified reports states.
|
||||
// @LAYER: UI
|
||||
|
||||
export const mixedTaskReports = [
|
||||
{
|
||||
report_id: "rep-001",
|
||||
task_id: "task-001",
|
||||
task_type: "llm_verification",
|
||||
status: "success",
|
||||
started_at: "2026-02-22T09:00:00Z",
|
||||
updated_at: "2026-02-22T09:00:30Z",
|
||||
summary: "LLM verification completed",
|
||||
details: { checks_performed: 12, issues_found: 1 }
|
||||
},
|
||||
{
|
||||
report_id: "rep-002",
|
||||
task_id: "task-002",
|
||||
task_type: "backup",
|
||||
status: "failed",
|
||||
started_at: "2026-02-22T09:10:00Z",
|
||||
updated_at: "2026-02-22T09:11:00Z",
|
||||
summary: "Backup failed due to storage limit",
|
||||
error_context: { message: "Not enough disk space", next_actions: ["Free storage", "Retry backup"] }
|
||||
},
|
||||
{
|
||||
report_id: "rep-003",
|
||||
task_id: "task-003",
|
||||
task_type: "migration",
|
||||
status: "in_progress",
|
||||
started_at: "2026-02-22T09:20:00Z",
|
||||
updated_at: "2026-02-22T09:21:00Z",
|
||||
summary: "Migration running",
|
||||
details: { progress_percent: 42 }
|
||||
},
|
||||
{
|
||||
report_id: "rep-004",
|
||||
task_id: "task-004",
|
||||
task_type: "documentation",
|
||||
status: "partial",
|
||||
started_at: "2026-02-22T09:30:00Z",
|
||||
updated_at: "2026-02-22T09:31:00Z",
|
||||
summary: "Documentation generated with partial coverage",
|
||||
error_context: { message: "Missing metadata for 3 columns", next_actions: ["Review missing metadata"] }
|
||||
}
|
||||
];
|
||||
|
||||
export const unknownTypePartialPayload = [
|
||||
{
|
||||
report_id: "rep-unknown-001",
|
||||
task_id: "task-unknown-001",
|
||||
task_type: "unknown",
|
||||
status: "failed",
|
||||
updated_at: "2026-02-22T10:00:00Z",
|
||||
summary: "Unknown task type failed",
|
||||
details: null
|
||||
},
|
||||
{
|
||||
report_id: "rep-partial-001",
|
||||
task_id: "task-partial-001",
|
||||
task_type: "backup",
|
||||
status: "success",
|
||||
updated_at: "2026-02-22T10:05:00Z",
|
||||
summary: "Backup completed",
|
||||
details: {}
|
||||
}
|
||||
];
|
||||
|
||||
export const reportCollections = {
|
||||
ready: {
|
||||
items: mixedTaskReports,
|
||||
total: mixedTaskReports.length,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
applied_filters: { page: 1, page_size: 20, sort_by: "updated_at", sort_order: "desc" }
|
||||
},
|
||||
empty: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
applied_filters: { page: 1, page_size: 20, sort_by: "updated_at", sort_order: "desc" }
|
||||
}
|
||||
};
|
||||
|
||||
// [/DEF:reports.fixtures:Module]
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.report_card.ux:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: reports, ux-tests, card, states, recovery
|
||||
// @PURPOSE: Test UX states and transitions for ReportCard component
|
||||
// @LAYER: UI
|
||||
// @RELATION: VERIFIES -> ../ReportCard.svelte
|
||||
// @INVARIANT: Each test asserts at least one observable UX contract outcome.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import ReportCard from '../ReportCard.svelte';
|
||||
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('$lib/i18n', () => ({
|
||||
t: {
|
||||
subscribe: (fn) => {
|
||||
fn({
|
||||
reports: {
|
||||
not_provided: 'Not provided',
|
||||
unknown_type: 'Other / Unknown Type'
|
||||
}
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
describe('ReportCard UX Contract', () => {
|
||||
const mockReport = mixedTaskReports[0]; // Success report
|
||||
|
||||
// @UX_STATE: Ready -> Card displays summary/status/type.
|
||||
it('should display summary, status and type in Ready state', () => {
|
||||
render(ReportCard, { report: mockReport });
|
||||
|
||||
expect(screen.getByText(mockReport.summary)).toBeDefined();
|
||||
expect(screen.getByText(mockReport.status)).toBeDefined();
|
||||
// Profile label for llm_verification is 'LLM'
|
||||
expect(screen.getByText('LLM')).toBeDefined();
|
||||
});
|
||||
|
||||
// @UX_FEEDBACK: Click on report emits select event.
|
||||
it('should emit select event on click', async () => {
|
||||
// In Svelte 5 / Vitest environment, we test event dispatching by passing the handler as a prop
|
||||
// with 'on' prefix (e.g., onselect) or by using standard event listeners if component supports them.
|
||||
const onSelect = vi.fn();
|
||||
render(ReportCard, { report: mockReport, onselect: onSelect });
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
await fireEvent.click(button);
|
||||
|
||||
// Note: Svelte 5 event dispatching testing depends on testing-library version and component implementation.
|
||||
});
|
||||
|
||||
// @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.
|
||||
it('should render placeholders for missing fields', () => {
|
||||
const partialReport = { report_id: 'partial-1' };
|
||||
render(ReportCard, { report: partialReport });
|
||||
|
||||
// Check placeholders (using text from mocked $t)
|
||||
const placeholders = screen.getAllByText('Not provided');
|
||||
expect(placeholders.length).toBeGreaterThan(0);
|
||||
|
||||
// Check fallback type
|
||||
expect(screen.getByText('Other / Unknown Type')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.report_card.ux:Module]
|
||||
@@ -1,45 +0,0 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.report_detail.integration:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: tests, reports, detail, recovery-guidance, integration
|
||||
// @PURPOSE: Validate detail-panel behavior for failed reports and recovery guidance visibility.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/reports/ReportDetailPanel.svelte
|
||||
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
|
||||
// @INVARIANT: Failed report detail exposes actionable next actions when available.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
|
||||
|
||||
function buildFailedDetailFixture() {
|
||||
const failed = mixedTaskReports.find((item) => item.status === 'failed');
|
||||
return {
|
||||
report: failed,
|
||||
diagnostics: {
|
||||
error_context: failed?.error_context || { message: 'Not provided', next_actions: [] }
|
||||
},
|
||||
next_actions: failed?.error_context?.next_actions || []
|
||||
};
|
||||
}
|
||||
|
||||
describe('report detail integration - failed report guidance', () => {
|
||||
it('failed fixture includes error context and next actions', () => {
|
||||
const detail = buildFailedDetailFixture();
|
||||
|
||||
expect(detail.report).toBeTruthy();
|
||||
expect(detail.report.status).toBe('failed');
|
||||
expect(detail.diagnostics).toBeTruthy();
|
||||
expect(Array.isArray(detail.next_actions)).toBe(true);
|
||||
expect(detail.next_actions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('next actions are human-readable strings for operator recovery', () => {
|
||||
const detail = buildFailedDetailFixture();
|
||||
|
||||
for (const action of detail.next_actions) {
|
||||
expect(typeof action).toBe('string');
|
||||
expect(action.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.report_detail.integration:Module]
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.report_detail.ux:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: reports, ux-tests, detail, diagnostics, recovery
|
||||
// @PURPOSE: Test UX states and recovery for ReportDetailPanel component
|
||||
// @LAYER: UI
|
||||
// @RELATION: VERIFIES -> ../ReportDetailPanel.svelte
|
||||
// @INVARIANT: Detail UX tests keep placeholder-safe rendering and recovery visibility verifiable.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import ReportDetailPanel from '../ReportDetailPanel.svelte';
|
||||
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('$lib/i18n', () => ({
|
||||
t: {
|
||||
subscribe: (fn) => {
|
||||
fn({
|
||||
reports: {
|
||||
not_provided: 'Not provided',
|
||||
view_details: 'View details'
|
||||
}
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
describe('ReportDetailPanel UX Contract', () => {
|
||||
const mockReport = mixedTaskReports[0];
|
||||
const mockDetail = {
|
||||
report: mockReport,
|
||||
timeline: [{ event: 'started', at: mockReport.started_at }],
|
||||
diagnostics: { note: 'All systems green' },
|
||||
next_actions: []
|
||||
};
|
||||
|
||||
// @UX_STATE: Ready -> Report detail content visible.
|
||||
it('should display report details in Ready state', () => {
|
||||
render(ReportDetailPanel, { detail: mockDetail });
|
||||
|
||||
expect(screen.getByText(mockReport.report_id)).toBeDefined();
|
||||
expect(screen.getByText(mockReport.summary)).toBeDefined();
|
||||
expect(screen.getByText(/All systems green/)).toBeDefined();
|
||||
});
|
||||
|
||||
// @UX_RECOVERY: Failed/partial report shows next actions and placeholder-safe diagnostics.
|
||||
it('should show recovery guidance for failed reports', () => {
|
||||
const failedReport = mixedTaskReports.find(r => r.status === 'failed');
|
||||
const failedDetail = {
|
||||
report: failedReport,
|
||||
diagnostics: { error: 'Disk full' },
|
||||
next_actions: ['Free storage', 'Retry']
|
||||
};
|
||||
|
||||
render(ReportDetailPanel, { detail: failedDetail });
|
||||
|
||||
expect(screen.getByText('Free storage')).toBeDefined();
|
||||
expect(screen.getByText('Retry')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render placeholders when no detail is provided', () => {
|
||||
render(ReportDetailPanel, { detail: null });
|
||||
|
||||
// Should show "Not provided" (from mocked $t)
|
||||
const placeholders = screen.getAllByText('Not provided');
|
||||
expect(placeholders.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.report_detail.ux:Module]
|
||||
@@ -1,32 +0,0 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.report_type_profiles:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: tests, reports, type-profiles, fallback
|
||||
// @PURPOSE: Validate report type profile mapping and unknown fallback behavior.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/reports/reportTypeProfiles.js
|
||||
// @INVARIANT: Unknown task_type always resolves to the fallback profile.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getReportTypeProfile, REPORT_TYPE_PROFILES } from '../reportTypeProfiles.js';
|
||||
|
||||
describe('report type profiles', () => {
|
||||
it('returns dedicated profiles for known task types', () => {
|
||||
expect(getReportTypeProfile('llm_verification').key).toBe('llm_verification');
|
||||
expect(getReportTypeProfile('backup').key).toBe('backup');
|
||||
expect(getReportTypeProfile('migration').key).toBe('migration');
|
||||
expect(getReportTypeProfile('documentation').key).toBe('documentation');
|
||||
});
|
||||
|
||||
it('returns fallback profile for unknown task type', () => {
|
||||
const profile = getReportTypeProfile('something_new');
|
||||
expect(profile.key).toBe('unknown');
|
||||
expect(profile.fallback).toBe(true);
|
||||
});
|
||||
|
||||
it('contains exactly one fallback profile in registry', () => {
|
||||
const fallbackCount = Object.values(REPORT_TYPE_PROFILES).filter((p) => p.fallback === true).length;
|
||||
expect(fallbackCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.report_type_profiles:Module]
|
||||
@@ -1,48 +0,0 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.reports_filter_performance:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: tests, reports, performance, filtering
|
||||
// @PURPOSE: Guard test for report filter responsiveness on moderate in-memory dataset.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function applyFilters(items, { taskType = 'all', status = 'all' } = {}) {
|
||||
return items.filter((item) => {
|
||||
const typeMatch = taskType === 'all' || item.task_type === taskType;
|
||||
const statusMatch = status === 'all' || item.status === status;
|
||||
return typeMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
function makeDataset(size = 2000) {
|
||||
const taskTypes = ['llm_verification', 'backup', 'migration', 'documentation'];
|
||||
const statuses = ['success', 'failed', 'in_progress', 'partial'];
|
||||
const out = [];
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
out.push({
|
||||
report_id: `r-${i}`,
|
||||
task_id: `t-${i}`,
|
||||
task_type: taskTypes[i % taskTypes.length],
|
||||
status: statuses[i % statuses.length],
|
||||
summary: `Report ${i}`,
|
||||
updated_at: '2026-02-22T10:00:00Z'
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('reports filter performance guard', () => {
|
||||
it('applies task_type+status filter quickly on 2000 records', () => {
|
||||
const dataset = makeDataset(2000);
|
||||
|
||||
const start = Date.now();
|
||||
const result = applyFilters(dataset, { taskType: 'migration', status: 'failed' });
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.reports_filter_performance:Module]
|
||||
@@ -1,40 +0,0 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.reports_page.integration:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: tests, reports, integration, mixed-types, rendering
|
||||
// @PURPOSE: Integration-style checks for unified mixed-type reports rendering expectations.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/reports/ReportsList.svelte
|
||||
// @INVARIANT: Mixed fixture includes all supported report types in one list.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
|
||||
|
||||
function collectVisibleTypeLabels(items) {
|
||||
return items.map((item) => item.task_type);
|
||||
}
|
||||
|
||||
describe('Reports page integration - unified mixed type rendering', () => {
|
||||
it('contains mixed reports from all primary task types in one payload', () => {
|
||||
const labels = collectVisibleTypeLabels(mixedTaskReports);
|
||||
|
||||
expect(labels).toContain('llm_verification');
|
||||
expect(labels).toContain('backup');
|
||||
expect(labels).toContain('migration');
|
||||
expect(labels).toContain('documentation');
|
||||
expect(mixedTaskReports.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('ensures canonical minimum fields are present for each report item', () => {
|
||||
for (const report of mixedTaskReports) {
|
||||
expect(typeof report.report_id).toBe('string');
|
||||
expect(typeof report.task_id).toBe('string');
|
||||
expect(typeof report.task_type).toBe('string');
|
||||
expect(typeof report.status).toBe('string');
|
||||
expect(typeof report.summary).toBe('string');
|
||||
expect(report.updated_at).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.reports_page.integration:Module]
|
||||
@@ -1,59 +0,0 @@
|
||||
// [DEF:frontend.src.lib.components.reports.reportTypeProfiles:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: reports, ui, profiles, fallback, mapping
|
||||
// @PURPOSE: Deterministic mapping from report task_type to visual profile with one fallback.
|
||||
// @LAYER: UI
|
||||
// @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
// @INVARIANT: Unknown type always resolves to fallback profile.
|
||||
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
export const REPORT_TYPE_PROFILES = {
|
||||
llm_verification: {
|
||||
key: 'llm_verification',
|
||||
label: 'LLM',
|
||||
variant: 'bg-violet-100 text-violet-700',
|
||||
icon: 'sparkles',
|
||||
fallback: false
|
||||
},
|
||||
backup: {
|
||||
key: 'backup',
|
||||
label: () => _('nav.backups'),
|
||||
variant: 'bg-emerald-100 text-emerald-700',
|
||||
icon: 'archive',
|
||||
fallback: false
|
||||
},
|
||||
migration: {
|
||||
key: 'migration',
|
||||
label: () => _('nav.migration'),
|
||||
variant: 'bg-amber-100 text-amber-700',
|
||||
icon: 'shuffle',
|
||||
fallback: false
|
||||
},
|
||||
documentation: {
|
||||
key: 'documentation',
|
||||
label: 'Documentation',
|
||||
variant: 'bg-sky-100 text-sky-700',
|
||||
icon: 'file-text',
|
||||
fallback: false
|
||||
},
|
||||
unknown: {
|
||||
key: 'unknown',
|
||||
label: () => _('reports.unknown_type'),
|
||||
variant: 'bg-slate-100 text-slate-700',
|
||||
icon: 'help-circle',
|
||||
fallback: true
|
||||
}
|
||||
};
|
||||
|
||||
// [DEF:getReportTypeProfile:Function]
|
||||
// @PURPOSE: Resolve visual profile by task type with guaranteed fallback.
|
||||
// @PRE: taskType may be known/unknown/empty.
|
||||
// @POST: Returns one profile object.
|
||||
export function getReportTypeProfile(taskType) {
|
||||
const key = typeof taskType === 'string' ? taskType : 'unknown';
|
||||
return REPORT_TYPE_PROFILES[key] || REPORT_TYPE_PROFILES.unknown;
|
||||
}
|
||||
// [/DEF:getReportTypeProfile:Function]
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.reportTypeProfiles:Module]
|
||||
@@ -25,7 +25,6 @@
|
||||
"migration": "Migration",
|
||||
"git": "Git",
|
||||
"tasks": "Tasks",
|
||||
"reports": "Reports",
|
||||
"settings": "Settings",
|
||||
"tools": "Tools",
|
||||
"tools_search": "Dataset Search",
|
||||
@@ -89,16 +88,14 @@
|
||||
"storage_repo_pattern": "Repository Directory Pattern",
|
||||
"storage_filename_pattern": "Filename Pattern",
|
||||
"storage_preview": "Path Preview",
|
||||
"environments": "Superset Environments",
|
||||
"env_description": "Configure Superset environments for dashboards and datasets.",
|
||||
"env_add": "Add Environment",
|
||||
"env_actions": "Actions",
|
||||
"env_test": "Test",
|
||||
"env_delete": "Delete",
|
||||
"connections_description": "Configure database connections for data mapping.",
|
||||
"llm_description": "Configure LLM providers for dataset documentation.",
|
||||
"llm_prompts_title": "LLM Prompt Templates",
|
||||
"llm_prompts_description": "Edit reusable prompts used for documentation, dashboard validation, and git commit generation.",
|
||||
"llm_prompt_documentation": "Documentation Prompt",
|
||||
"llm_prompt_dashboard_validation": "Dashboard Validation Prompt",
|
||||
"llm_prompt_git_commit": "Git Commit Prompt",
|
||||
"save_llm_prompts": "Save LLM Prompts",
|
||||
"logging": "Logging Configuration",
|
||||
"logging_description": "Configure logging and task log levels.",
|
||||
"storage_description": "Configure file storage paths and patterns.",
|
||||
@@ -163,6 +160,8 @@
|
||||
"action_migrate": "Migrate",
|
||||
"action_backup": "Backup",
|
||||
"action_commit": "Commit",
|
||||
"git_status": "Git Status",
|
||||
"last_task": "Last Task",
|
||||
"view_task": "View task",
|
||||
"task_running": "Running...",
|
||||
"task_done": "Done",
|
||||
@@ -170,26 +169,16 @@
|
||||
"task_waiting": "Waiting",
|
||||
"status_synced": "Synced",
|
||||
"status_diff": "Diff",
|
||||
"status_synced": "Synced",
|
||||
"status_diff": "Diff",
|
||||
"status_error": "Error",
|
||||
"task_running": "Running...",
|
||||
"task_done": "Done",
|
||||
"task_failed": "Failed",
|
||||
"task_waiting": "Waiting",
|
||||
"view_task": "View task",
|
||||
"empty": "No dashboards found"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"empty": "No reports available.",
|
||||
"filtered_empty": "No reports match your filters.",
|
||||
"loading": "Loading reports...",
|
||||
"retry_load": "Retry loading",
|
||||
"clear_filters": "Clear filters",
|
||||
"unknown_type": "Other / Unknown Type",
|
||||
"not_provided": "Not provided",
|
||||
"view_details": "View details",
|
||||
"diagnostics": "Diagnostics",
|
||||
"next_actions": "Next actions",
|
||||
"status_success": "Success",
|
||||
"status_failed": "Failed",
|
||||
"status_in_progress": "In progress",
|
||||
"status_partial": "Partial"
|
||||
},
|
||||
"datasets": {
|
||||
"empty": "No datasets found",
|
||||
"table_name": "Table Name",
|
||||
@@ -227,24 +216,6 @@
|
||||
"cron_hint": "e.g., 0 0 * * * for daily at midnight",
|
||||
"footer_text": "Task continues running in background"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "AI Assistant",
|
||||
"open": "Open assistant",
|
||||
"close": "Close assistant",
|
||||
"send": "Send",
|
||||
"input_placeholder": "Type a command...",
|
||||
"loading_history": "Loading history...",
|
||||
"try_commands": "Try commands:",
|
||||
"open_task_drawer": "Open Task Drawer",
|
||||
"states": {
|
||||
"started": "Started",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"needs_confirmation": "Needs confirmation",
|
||||
"needs_clarification": "Needs clarification",
|
||||
"denied": "Denied"
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"management": "Connection Management",
|
||||
"add_new": "Add New Connection",
|
||||
@@ -363,4 +334,4 @@
|
||||
"select_role": "Select a role"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user