Compare commits

..

22 Commits

Author SHA1 Message Date
d10c23e658 Обновил gitignore - убрал логи 2026-01-26 22:15:17 +03:00
1042b35d1b Закончили редизайн, обновили интерфейс бэкапа 2026-01-26 22:12:35 +03:00
16ffeb1ed6 Выполнено, передано на тестирование 2026-01-26 21:17:05 +03:00
da34deac02 tasks ready 2026-01-26 20:58:38 +03:00
51e9ee3fcc semantic update 2026-01-26 11:57:36 +03:00
edf9286071 Файловое хранилище готово 2026-01-26 11:08:18 +03:00
a542e7d2df Передаем на тест 2026-01-25 18:33:00 +03:00
a863807cf2 tasks ready 2026-01-24 16:21:43 +03:00
e2bc68683f Update .gitignore 2026-01-24 11:26:19 +03:00
43cb82697b Update backup scheduler task status 2026-01-24 11:26:05 +03:00
4ba28cf93e semantic cleanup 2026-01-23 21:58:32 +03:00
343f2e29f5 Мультиязночность + причесывание css 2026-01-23 17:53:46 +03:00
c9a53578fd tasks ready 2026-01-23 14:56:05 +03:00
07ec2d9797 Работает создание коммитов и перенос в новый enviroment 2026-01-23 13:57:44 +03:00
e9d3f3c827 tasks ready 2026-01-22 23:59:16 +03:00
26ba015b75 +gitignore 2026-01-22 23:25:29 +03:00
49129d3e86 fix error 2026-01-22 23:18:48 +03:00
d99a13d91f refactor complete 2026-01-22 17:37:17 +03:00
203ce446f4 ашч 2026-01-21 14:00:48 +03:00
c96d50a3f4 fix(backend): standardize superset client init and auth
- Update plugins (debug, mapper, search) to explicitly map environment config to SupersetConfig
- Add authenticate method to SupersetClient for explicit session management
- Add get_environment method to ConfigManager
- Fix navbar dropdown hover stability in frontend with invisible bridge
2026-01-20 19:31:17 +03:00
3bbe320949 TaskLog fix 2026-01-19 17:10:43 +03:00
2d2435642d bug fixs 2026-01-19 00:07:06 +03:00
169 changed files with 94937 additions and 7811 deletions

4
.gitignore vendored
View File

@@ -59,9 +59,11 @@ Thumbs.db
*.ps1 *.ps1
keyring passwords.py keyring passwords.py
*github* *github*
*git*
*tech_spec* *tech_spec*
dashboards dashboards
backend/mappings.db backend/mappings.db
backend/tasks.db
backend/logs

View File

@@ -1 +1 @@
{"mcpServers":{"tavily":{"command":"npx","args":["-y","tavily-mcp@0.2.3"],"env":{"TAVILY_API_KEY":"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"},"alwaysAllow":[]}}} {"mcpServers":{}}

View File

@@ -20,6 +20,17 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- SQLite (`tasks.db`), JSON (`config.json`) (009-backup-scheduler) - SQLite (`tasks.db`), JSON (`config.json`) (009-backup-scheduler)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, `superset_tool` (internal lib) (010-refactor-cli-to-web) - Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, `superset_tool` (internal lib) (010-refactor-cli-to-web)
- SQLite (for job history/results, connection configs), Filesystem (for temporary file uploads) (010-refactor-cli-to-web) - SQLite (for job history/results, connection configs), Filesystem (for temporary file uploads) (010-refactor-cli-to-web)
- Python 3.9+ + FastAPI, Pydantic, requests, pyyaml (migrated from superset_tool) (012-remove-superset-tool)
- SQLite (tasks.db, migrations.db), Filesystem (012-remove-superset-tool)
- Filesystem (local git repo), SQLite (for GitServerConfig, Environment) (011-git-integration-dashboard)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API (011-git-integration-dashboard)
- SQLite (for config/history), Filesystem (local Git repositories) (011-git-integration-dashboard)
- Node.js 18+ (Frontend Build), Svelte 5.x + SvelteKit, Tailwind CSS, `date-fns` (existing) (013-unify-frontend-css)
- LocalStorage (for language preference) (013-unify-frontend-css)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit (Frontend) (014-file-storage-ui)
- Local Filesystem (for artifacts), Config (for storage path) (014-file-storage-ui)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend) (015-frontend-nav-redesign)
- N/A (UI reorganization and API integration) (015-frontend-nav-redesign)
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui) - Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
@@ -40,9 +51,9 @@ cd src; pytest; ruff check .
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes ## Recent Changes
- 010-refactor-cli-to-web: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, `superset_tool` (internal lib) - 015-frontend-nav-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend)
- 009-backup-scheduler: Added Python 3.9+, Node.js 18+ + FastAPI, APScheduler, SQLAlchemy, SvelteKit, Tailwind CSS - 014-file-storage-ui: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit (Frontend)
- 009-backup-scheduler: Added Python 3.9+, Node.js 18+ + FastAPI, APScheduler, SQLAlchemy, SvelteKit, Tailwind CSS - 013-unify-frontend-css: Added Node.js 18+ (Frontend Build), Svelte 5.x + SvelteKit, Tailwind CSS, `date-fns` (existing)
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->

View File

@@ -1,11 +1,10 @@
<!-- <!--
SYNC IMPACT REPORT SYNC IMPACT REPORT
Version: 1.7.1 (Simplified Workflow) Version: 1.8.0 (Frontend Unification)
Changes: Changes:
- Simplified Generation Workflow to a single phase: Code Generation from `tasks.md`. - Added Principle VIII: Unified Frontend Experience (Mandating Design System & i18n).
- Removed multi-phase Architecture/Implementation split to streamline development.
Templates Status: Templates Status:
- .specify/templates/plan-template.md: ✅ Aligned (Dynamic check). - .specify/templates/plan-template.md: ✅ Aligned.
- .specify/templates/spec-template.md: ✅ Aligned. - .specify/templates/spec-template.md: ✅ Aligned.
- .specify/templates/tasks-template.md: ✅ Aligned. - .specify/templates/tasks-template.md: ✅ Aligned.
--> -->
@@ -37,6 +36,11 @@ To maintain semantic coherence, code must adhere to the complexity limits (Modul
### VII. Everything is a Plugin ### VII. Everything is a Plugin
All functional extensions, tools, or major features must be implemented as modular Plugins inheriting from `PluginBase`. Logic should not reside in standalone services or scripts unless strictly necessary for core infrastructure. This ensures a unified execution model via the `TaskManager`, consistent logging, and modularity. All functional extensions, tools, or major features must be implemented as modular Plugins inheriting from `PluginBase`. Logic should not reside in standalone services or scripts unless strictly necessary for core infrastructure. This ensures a unified execution model via the `TaskManager`, consistent logging, and modularity.
### VIII. Unified Frontend Experience
To ensure a consistent and accessible user experience, all frontend implementations must strictly adhere to the unified design and localization standards.
- **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens. Ad-hoc styling and hardcoded values are prohibited.
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`). Hardcoded strings in the UI are prohibited.
## File Structure Standards ## File Structure Standards
Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of: Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of:
- Python Module Headers (`.py`) - Python Module Headers (`.py`)
@@ -64,4 +68,4 @@ This Constitution establishes the "Semantic Code Generation Protocol" as the sup
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update. - **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure. - **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
**Version**: 1.7.1 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-13 **Version**: 1.8.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-26

Submodule backend/backend/git_repos/12 added at f46772443a

View File

@@ -1,269 +0,0 @@
2025-12-20 19:55:11,325 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 19:55:11,325 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 19:55:11,327 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 43, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 21:01:49,905 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 21:01:49,906 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 21:01:49,988 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 21:01:49,990 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 22:42:32,538 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 22:42:32,538 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 22:42:32,583 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 22:42:32,587 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 22:54:29,770 - INFO - [BackupPlugin][Entry] Starting backup for .
2025-12-20 22:54:29,771 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 22:54:29,831 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 22:54:29,833 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 22:54:34,078 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 22:54:34,078 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 22:54:34,079 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 22:54:34,079 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 22:59:25,060 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 22:59:25,060 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 22:59:25,114 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 22:59:25,117 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:00:31,156 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:00:31,156 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:00:31,157 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:00:31,162 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:00:34,710 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:00:34,710 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:00:34,710 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:00:34,711 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:01:43,894 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:01:43,894 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:01:43,895 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:01:43,895 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:04:07,731 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:04:07,731 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:04:07,732 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:04:07,732 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:06:39,641 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:06:39,642 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:06:39,687 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:06:39,689 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:30:36,090 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:30:36,093 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:30:36,128 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:30:36,129 - INFO - [SupersetClient.__init__][Enter] Initializing SupersetClient.
2025-12-20 23:30:36,129 - INFO - [APIClient.__init__][Entry] Initializing APIClient.
2025-12-20 23:30:36,130 - WARNING - [_init_session][State] SSL verification disabled.
2025-12-20 23:30:36,130 - INFO - [APIClient.__init__][Exit] APIClient initialized.
2025-12-20 23:30:36,130 - INFO - [SupersetClient.__init__][Exit] SupersetClient initialized.
2025-12-20 23:30:36,130 - INFO - [get_dashboards][Enter] Fetching dashboards.
2025-12-20 23:30:36,131 - INFO - [authenticate][Enter] Authenticating to https://superset.bebesh.ru/api/v1
2025-12-20 23:30:36,897 - INFO - [authenticate][Exit] Authenticated successfully.
2025-12-20 23:30:37,527 - INFO - [get_dashboards][Exit] Found 11 dashboards.
2025-12-20 23:30:37,527 - INFO - [BackupPlugin][Progress] Found 11 dashboards to export in superset.
2025-12-20 23:30:37,529 - INFO - [export_dashboard][Enter] Exporting dashboard 11.
2025-12-20 23:30:38,224 - INFO - [export_dashboard][Exit] Exported dashboard 11 to dashboard_export_20251220T203037.zip.
2025-12-20 23:30:38,225 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:38,226 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/FCC New Coder Survey 2018/dashboard_export_20251220T203037.zip
2025-12-20 23:30:38,227 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/FCC New Coder Survey 2018
2025-12-20 23:30:38,230 - INFO - [export_dashboard][Enter] Exporting dashboard 10.
2025-12-20 23:30:38,438 - INFO - [export_dashboard][Exit] Exported dashboard 10 to dashboard_export_20251220T203038.zip.
2025-12-20 23:30:38,438 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:38,439 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/COVID Vaccine Dashboard/dashboard_export_20251220T203038.zip
2025-12-20 23:30:38,439 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/COVID Vaccine Dashboard
2025-12-20 23:30:38,440 - INFO - [export_dashboard][Enter] Exporting dashboard 9.
2025-12-20 23:30:38,853 - INFO - [export_dashboard][Exit] Exported dashboard 9 to dashboard_export_20251220T203038.zip.
2025-12-20 23:30:38,853 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:38,856 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Sales Dashboard/dashboard_export_20251220T203038.zip
2025-12-20 23:30:38,856 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Sales Dashboard
2025-12-20 23:30:38,858 - INFO - [export_dashboard][Enter] Exporting dashboard 8.
2025-12-20 23:30:38,939 - INFO - [export_dashboard][Exit] Exported dashboard 8 to dashboard_export_20251220T203038.zip.
2025-12-20 23:30:38,940 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:38,941 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Unicode Test/dashboard_export_20251220T203038.zip
2025-12-20 23:30:38,941 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Unicode Test
2025-12-20 23:30:38,942 - INFO - [export_dashboard][Enter] Exporting dashboard 7.
2025-12-20 23:30:39,148 - INFO - [export_dashboard][Exit] Exported dashboard 7 to dashboard_export_20251220T203038.zip.
2025-12-20 23:30:39,148 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:39,149 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Video Game Sales/dashboard_export_20251220T203038.zip
2025-12-20 23:30:39,149 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Video Game Sales
2025-12-20 23:30:39,150 - INFO - [export_dashboard][Enter] Exporting dashboard 6.
2025-12-20 23:30:39,689 - INFO - [export_dashboard][Exit] Exported dashboard 6 to dashboard_export_20251220T203039.zip.
2025-12-20 23:30:39,689 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:39,690 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Featured Charts/dashboard_export_20251220T203039.zip
2025-12-20 23:30:39,691 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Featured Charts
2025-12-20 23:30:39,692 - INFO - [export_dashboard][Enter] Exporting dashboard 5.
2025-12-20 23:30:39,960 - INFO - [export_dashboard][Exit] Exported dashboard 5 to dashboard_export_20251220T203039.zip.
2025-12-20 23:30:39,960 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:39,961 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Slack Dashboard/dashboard_export_20251220T203039.zip
2025-12-20 23:30:39,961 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Slack Dashboard
2025-12-20 23:30:39,962 - INFO - [export_dashboard][Enter] Exporting dashboard 4.
2025-12-20 23:30:40,196 - INFO - [export_dashboard][Exit] Exported dashboard 4 to dashboard_export_20251220T203039.zip.
2025-12-20 23:30:40,196 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:40,197 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/deck.gl Demo/dashboard_export_20251220T203039.zip
2025-12-20 23:30:40,197 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/deck.gl Demo
2025-12-20 23:30:40,198 - INFO - [export_dashboard][Enter] Exporting dashboard 3.
2025-12-20 23:30:40,745 - INFO - [export_dashboard][Exit] Exported dashboard 3 to dashboard_export_20251220T203040.zip.
2025-12-20 23:30:40,746 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:40,760 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Misc Charts/dashboard_export_20251220T203040.zip
2025-12-20 23:30:40,761 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Misc Charts
2025-12-20 23:30:40,762 - INFO - [export_dashboard][Enter] Exporting dashboard 2.
2025-12-20 23:30:40,928 - INFO - [export_dashboard][Exit] Exported dashboard 2 to dashboard_export_20251220T203040.zip.
2025-12-20 23:30:40,929 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:40,930 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/USA Births Names/dashboard_export_20251220T203040.zip
2025-12-20 23:30:40,931 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/USA Births Names
2025-12-20 23:30:40,932 - INFO - [export_dashboard][Enter] Exporting dashboard 1.
2025-12-20 23:30:41,582 - INFO - [export_dashboard][Exit] Exported dashboard 1 to dashboard_export_20251220T203040.zip.
2025-12-20 23:30:41,582 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:41,749 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/World Bank's Data/dashboard_export_20251220T203040.zip
2025-12-20 23:30:41,750 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/World Bank's Data
2025-12-20 23:30:41,752 - INFO - [consolidate_archive_folders][Enter] Consolidating archives in backups/SUPERSET
2025-12-20 23:30:41,753 - INFO - [remove_empty_directories][Enter] Starting cleanup of empty directories in backups/SUPERSET
2025-12-20 23:30:41,758 - INFO - [remove_empty_directories][Exit] Removed 0 empty directories.
2025-12-20 23:30:41,758 - INFO - [BackupPlugin][CoherenceCheck:Passed] Backup logic completed for superset.

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
# [DEF:backend.delete_running_tasks:Module]
# @PURPOSE: Script to delete tasks with RUNNING status from the database.
# @LAYER: Utility
# @SEMANTICS: maintenance, database, cleanup
from sqlalchemy.orm import Session
from src.core.database import TasksSessionLocal
from src.models.task import TaskRecord
# [DEF:delete_running_tasks:Function]
# @PURPOSE: Delete all tasks with RUNNING status from the database.
# @PRE: Database is accessible and TaskRecord model is defined.
# @POST: All tasks with status 'RUNNING' are removed from the database.
def delete_running_tasks():
"""Delete all tasks with RUNNING status from the database."""
session: Session = TasksSessionLocal()
try:
# Find all task records with RUNNING status
running_tasks = session.query(TaskRecord).filter(TaskRecord.status == "RUNNING").all()
if not running_tasks:
print("No RUNNING tasks found.")
return
print(f"Found {len(running_tasks)} RUNNING tasks:")
for task in running_tasks:
print(f"- Task ID: {task.id}, Type: {task.type}")
# Delete the found tasks
session.query(TaskRecord).filter(TaskRecord.status == "RUNNING").delete(synchronize_session=False)
session.commit()
print(f"Successfully deleted {len(running_tasks)} RUNNING tasks.")
except Exception as e:
session.rollback()
print(f"Error deleting tasks: {e}")
finally:
session.close()
# [/DEF:delete_running_tasks:Function]
if __name__ == "__main__":
delete_running_tasks()
# [/DEF:backend.delete_running_tasks:Module]

79101
backend/logs/app.log.1 Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -42,4 +42,6 @@ urllib3==2.6.2
uvicorn==0.38.0 uvicorn==0.38.0
websockets==15.0.1 websockets==15.0.1
pandas pandas
psycopg2-binary psycopg2-binary
openpyxl
GitPython==3.1.44

View File

@@ -1 +1 @@
from . import plugins, tasks, settings, connections from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage

View File

@@ -11,12 +11,11 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict, Optional from typing import List, Dict, Optional
from backend.src.dependencies import get_config_manager, get_scheduler_service from ...dependencies import get_config_manager, get_scheduler_service
from backend.src.core.superset_client import SupersetClient from ...core.superset_client import SupersetClient
from superset_tool.models import SupersetConfig
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from backend.src.core.config_models import Environment as EnvModel from ...core.config_models import Environment as EnvModel
from backend.src.core.logger import belief_scope from ...core.logger import belief_scope
# [/SECTION] # [/SECTION]
router = APIRouter() router = APIRouter()
@@ -24,7 +23,7 @@ router = APIRouter()
# [DEF:ScheduleSchema:DataClass] # [DEF:ScheduleSchema:DataClass]
class ScheduleSchema(BaseModel): class ScheduleSchema(BaseModel):
enabled: bool = False enabled: bool = False
cron_expression: str = Field(..., pattern=r'^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|((((\d+,)*\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})$') cron_expression: str = Field(..., pattern=r'^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|((((\d+,)*\d+|(\d+(\/|-)\d+)|\d+|\*) ?){4,6})$')
# [/DEF:ScheduleSchema:DataClass] # [/DEF:ScheduleSchema:DataClass]
# [DEF:EnvironmentResponse:DataClass] # [DEF:EnvironmentResponse:DataClass]
@@ -62,7 +61,7 @@ async def get_environments(config_manager=Depends(get_config_manager)):
backup_schedule=ScheduleSchema( backup_schedule=ScheduleSchema(
enabled=e.backup_schedule.enabled, enabled=e.backup_schedule.enabled,
cron_expression=e.backup_schedule.cron_expression cron_expression=e.backup_schedule.cron_expression
) if e.backup_schedule else None ) if getattr(e, 'backup_schedule', None) else None
) for e in envs ) for e in envs
] ]
# [/DEF:get_environments:Function] # [/DEF:get_environments:Function]
@@ -114,18 +113,7 @@ async def get_environment_databases(id: str, config_manager=Depends(get_config_m
try: try:
# Initialize SupersetClient from environment config # Initialize SupersetClient from environment config
# Note: We need to map Environment model to SupersetConfig client = SupersetClient(env)
superset_config = SupersetConfig(
env=env.name,
base_url=env.url,
auth={
"provider": "db", # Defaulting to db provider
"username": env.username,
"password": env.password,
"refresh": "false"
}
)
client = SupersetClient(superset_config)
return client.get_databases_summary() return client.get_databases_summary()
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch databases: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to fetch databases: {str(e)}")

View File

@@ -0,0 +1,337 @@
# [DEF:backend.src.api.routes.git:Module]
#
# @SEMANTICS: git, routes, api, fastapi, repository, deployment
# @PURPOSE: Provides FastAPI endpoints for Git integration operations.
# @LAYER: API
# @RELATION: USES -> src.services.git_service.GitService
# @RELATION: USES -> src.api.routes.git_schemas
# @RELATION: USES -> src.models.git
#
# @INVARIANT: All Git operations must be routed through GitService.
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
import typing
from src.dependencies import get_config_manager
from src.core.database import get_db
from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository
from src.api.routes.git_schemas import (
GitServerConfigSchema, GitServerConfigCreate,
GitRepositorySchema, BranchSchema, BranchCreate,
BranchCheckout, CommitSchema, CommitCreate,
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest
)
from src.services.git_service import GitService
from src.core.logger import logger, belief_scope
router = APIRouter(prefix="/api/git", tags=["git"])
git_service = GitService()
# [DEF:get_git_configs:Function]
# @PURPOSE: List all configured Git servers.
# @PRE: Database session `db` is available.
# @POST: Returns a list of all GitServerConfig objects from the database.
# @RETURN: List[GitServerConfigSchema]
@router.get("/config", response_model=List[GitServerConfigSchema])
async def get_git_configs(db: Session = Depends(get_db)):
with belief_scope("get_git_configs"):
return db.query(GitServerConfig).all()
# [/DEF:get_git_configs:Function]
# [DEF:create_git_config:Function]
# @PURPOSE: Register a new Git server configuration.
# @PRE: `config` contains valid GitServerConfigCreate data.
# @POST: A new GitServerConfig record is created in the database.
# @PARAM: config (GitServerConfigCreate)
# @RETURN: GitServerConfigSchema
@router.post("/config", response_model=GitServerConfigSchema)
async def create_git_config(config: GitServerConfigCreate, db: Session = Depends(get_db)):
with belief_scope("create_git_config"):
db_config = GitServerConfig(**config.dict())
db.add(db_config)
db.commit()
db.refresh(db_config)
return db_config
# [/DEF:create_git_config:Function]
# [DEF:delete_git_config:Function]
# @PURPOSE: Remove a Git server configuration.
# @PRE: `config_id` corresponds to an existing configuration.
# @POST: The configuration record is removed from the database.
# @PARAM: config_id (str)
@router.delete("/config/{config_id}")
async def delete_git_config(config_id: str, db: Session = Depends(get_db)):
with belief_scope("delete_git_config"):
db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
if not db_config:
raise HTTPException(status_code=404, detail="Configuration not found")
db.delete(db_config)
db.commit()
return {"status": "success", "message": "Configuration deleted"}
# [/DEF:delete_git_config:Function]
# [DEF:test_git_config:Function]
# @PURPOSE: Validate connection to a Git server using provided credentials.
# @PRE: `config` contains provider, url, and pat.
# @POST: Returns success if the connection is validated via GitService.
# @PARAM: config (GitServerConfigCreate)
@router.post("/config/test")
async def test_git_config(config: GitServerConfigCreate):
with belief_scope("test_git_config"):
success = await git_service.test_connection(config.provider, config.url, config.pat)
if success:
return {"status": "success", "message": "Connection successful"}
else:
raise HTTPException(status_code=400, detail="Connection failed")
# [/DEF:test_git_config:Function]
# [DEF:init_repository:Function]
# @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init.
# @PRE: `dashboard_id` exists and `init_data` contains valid config_id and remote_url.
# @POST: Repository is initialized on disk and a GitRepository record is saved in DB.
# @PARAM: dashboard_id (int)
# @PARAM: init_data (RepoInitRequest)
@router.post("/repositories/{dashboard_id}/init")
async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Session = Depends(get_db)):
with belief_scope("init_repository"):
# 1. Get config
config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first()
if not config:
raise HTTPException(status_code=404, detail="Git configuration not found")
try:
# 2. Perform Git clone/init
logger.info(f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}")
git_service.init_repo(dashboard_id, init_data.remote_url, config.pat)
# 3. Save to DB
repo_path = git_service._get_repo_path(dashboard_id)
db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
if not db_repo:
db_repo = GitRepository(
dashboard_id=dashboard_id,
config_id=config.id,
remote_url=init_data.remote_url,
local_path=repo_path
)
db.add(db_repo)
else:
db_repo.config_id = config.id
db_repo.remote_url = init_data.remote_url
db_repo.local_path = repo_path
db.commit()
logger.info(f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}")
return {"status": "success", "message": "Repository initialized"}
except Exception as e:
db.rollback()
logger.error(f"[init_repository][Coherence:Failed] Failed to init repository: {e}")
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:init_repository:Function]
# [DEF:get_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository.
# @PRE: Repository for `dashboard_id` is initialized.
# @POST: Returns a list of branches from the local repository.
# @PARAM: dashboard_id (int)
# @RETURN: List[BranchSchema]
@router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema])
async def get_branches(dashboard_id: int):
with belief_scope("get_branches"):
try:
return git_service.list_branches(dashboard_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
# [/DEF:get_branches:Function]
# [DEF:create_branch:Function]
# @PURPOSE: Create a new branch in the dashboard's repository.
# @PRE: `dashboard_id` repository exists and `branch_data` has name and from_branch.
# @POST: A new branch is created in the local repository.
# @PARAM: dashboard_id (int)
# @PARAM: branch_data (BranchCreate)
@router.post("/repositories/{dashboard_id}/branches")
async def create_branch(dashboard_id: int, branch_data: BranchCreate):
with belief_scope("create_branch"):
try:
git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function]
# @PURPOSE: Switch the dashboard's repository to a specific branch.
# @PRE: `dashboard_id` repository exists and branch `checkout_data.name` exists.
# @POST: The local repository HEAD is moved to the specified branch.
# @PARAM: dashboard_id (int)
# @PARAM: checkout_data (BranchCheckout)
@router.post("/repositories/{dashboard_id}/checkout")
async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout):
with belief_scope("checkout_branch"):
try:
git_service.checkout_branch(dashboard_id, checkout_data.name)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:checkout_branch:Function]
# [DEF:commit_changes:Function]
# @PURPOSE: Stage and commit changes in the dashboard's repository.
# @PRE: `dashboard_id` repository exists and `commit_data` has message and files.
# @POST: Specified files are staged and a new commit is created.
# @PARAM: dashboard_id (int)
# @PARAM: commit_data (CommitCreate)
@router.post("/repositories/{dashboard_id}/commit")
async def commit_changes(dashboard_id: int, commit_data: CommitCreate):
with belief_scope("commit_changes"):
try:
git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:commit_changes:Function]
# [DEF:push_changes:Function]
# @PURPOSE: Push local commits to the remote repository.
# @PRE: `dashboard_id` repository exists and has a remote configured.
# @POST: Local commits are pushed to the remote repository.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/push")
async def push_changes(dashboard_id: int):
with belief_scope("push_changes"):
try:
git_service.push_changes(dashboard_id)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:push_changes:Function]
# [DEF:pull_changes:Function]
# @PURPOSE: Pull changes from the remote repository.
# @PRE: `dashboard_id` repository exists and has a remote configured.
# @POST: Remote changes are fetched and merged into the local branch.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/pull")
async def pull_changes(dashboard_id: int):
with belief_scope("pull_changes"):
try:
git_service.pull_changes(dashboard_id)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:pull_changes:Function]
# [DEF:sync_dashboard:Function]
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
# @PRE: `dashboard_id` is valid; GitPlugin is available.
# @POST: Dashboard YAMLs are exported from Superset and committed to Git.
# @PARAM: dashboard_id (int)
# @PARAM: source_env_id (Optional[str])
@router.post("/repositories/{dashboard_id}/sync")
async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str] = None):
with belief_scope("sync_dashboard"):
try:
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
return await plugin.execute({
"operation": "sync",
"dashboard_id": dashboard_id,
"source_env_id": source_env_id
})
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:sync_dashboard:Function]
# [DEF:get_environments:Function]
# @PURPOSE: List all deployment environments.
# @PRE: Config manager is accessible.
# @POST: Returns a list of DeploymentEnvironmentSchema objects.
# @RETURN: List[DeploymentEnvironmentSchema]
@router.get("/environments", response_model=List[DeploymentEnvironmentSchema])
async def get_environments(config_manager=Depends(get_config_manager)):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
return [
DeploymentEnvironmentSchema(
id=e.id,
name=e.name,
superset_url=e.url,
is_active=True
) for e in envs
]
# [/DEF:get_environments:Function]
# [DEF:deploy_dashboard:Function]
# @PURPOSE: Deploy dashboard from Git to a target environment.
# @PRE: `dashboard_id` and `deploy_data.environment_id` are valid.
# @POST: Dashboard YAMLs are read from Git and imported into the target Superset.
# @PARAM: dashboard_id (int)
# @PARAM: deploy_data (DeployRequest)
@router.post("/repositories/{dashboard_id}/deploy")
async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
with belief_scope("deploy_dashboard"):
try:
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
return await plugin.execute({
"operation": "deploy",
"dashboard_id": dashboard_id,
"environment_id": deploy_data.environment_id
})
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:deploy_dashboard:Function]
# [DEF:get_history:Function]
# @PURPOSE: View commit history for a dashboard's repository.
# @PRE: `dashboard_id` repository exists.
# @POST: Returns a list of recent commits from the repository.
# @PARAM: dashboard_id (int)
# @PARAM: limit (int)
# @RETURN: List[CommitSchema]
@router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema])
async def get_history(dashboard_id: int, limit: int = 50):
with belief_scope("get_history"):
try:
return git_service.get_commit_history(dashboard_id, limit)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
# [/DEF:get_history:Function]
# [DEF:get_repository_status:Function]
# @PURPOSE: Get current Git status for a dashboard repository.
# @PRE: `dashboard_id` repository exists.
# @POST: Returns the status of the working directory (staged, unstaged, untracked).
# @PARAM: dashboard_id (int)
# @RETURN: dict
@router.get("/repositories/{dashboard_id}/status")
async def get_repository_status(dashboard_id: int):
with belief_scope("get_repository_status"):
try:
return git_service.get_status(dashboard_id)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:get_repository_status:Function]
# [DEF:get_repository_diff:Function]
# @PURPOSE: Get Git diff for a dashboard repository.
# @PRE: `dashboard_id` repository exists.
# @POST: Returns the diff text for the specified file or all changes.
# @PARAM: dashboard_id (int)
# @PARAM: file_path (Optional[str])
# @PARAM: staged (bool)
# @RETURN: str
@router.get("/repositories/{dashboard_id}/diff")
async def get_repository_diff(dashboard_id: int, file_path: Optional[str] = None, staged: bool = False):
with belief_scope("get_repository_diff"):
try:
diff_text = git_service.get_diff(dashboard_id, file_path, staged)
return diff_text
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:get_repository_diff:Function]
# [/DEF:backend.src.api.routes.git:Module]

View File

@@ -0,0 +1,143 @@
# [DEF:backend.src.api.routes.git_schemas:Module]
#
# @SEMANTICS: git, schemas, pydantic, api, contracts
# @PURPOSE: Defines Pydantic models for the Git integration API layer.
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.models.git
#
# @INVARIANT: All schemas must be compatible with the FastAPI router.
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from uuid import UUID
from src.models.git import GitProvider, GitStatus, SyncStatus
# [DEF:GitServerConfigBase:Class]
# @PURPOSE: Base schema for Git server configuration attributes.
class GitServerConfigBase(BaseModel):
name: str = Field(..., description="Display name for the Git server")
provider: GitProvider = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)")
url: str = Field(..., description="Server base URL")
pat: str = Field(..., description="Personal Access Token")
default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)")
# [/DEF:GitServerConfigBase:Class]
# [DEF:GitServerConfigCreate:Class]
# @PURPOSE: Schema for creating a new Git server configuration.
class GitServerConfigCreate(GitServerConfigBase):
"""Schema for creating a new Git server configuration."""
pass
# [/DEF:GitServerConfigCreate:Class]
# [DEF:GitServerConfigSchema:Class]
# @PURPOSE: Schema for representing a Git server configuration with metadata.
class GitServerConfigSchema(GitServerConfigBase):
"""Schema for representing a Git server configuration with metadata."""
id: str
status: GitStatus
last_validated: datetime
class Config:
from_attributes = True
# [/DEF:GitServerConfigSchema:Class]
# [DEF:GitRepositorySchema:Class]
# @PURPOSE: Schema for tracking a local Git repository linked to a dashboard.
class GitRepositorySchema(BaseModel):
"""Schema for tracking a local Git repository linked to a dashboard."""
id: str
dashboard_id: int
config_id: str
remote_url: str
local_path: str
current_branch: str
sync_status: SyncStatus
class Config:
from_attributes = True
# [/DEF:GitRepositorySchema:Class]
# [DEF:BranchSchema:Class]
# @PURPOSE: Schema for representing a Git branch metadata.
class BranchSchema(BaseModel):
"""Schema for representing a Git branch."""
name: str
commit_hash: str
is_remote: bool
last_updated: datetime
# [/DEF:BranchSchema:Class]
# [DEF:CommitSchema:Class]
# @PURPOSE: Schema for representing Git commit details.
class CommitSchema(BaseModel):
"""Schema for representing a Git commit."""
hash: str
author: str
email: str
timestamp: datetime
message: str
files_changed: List[str]
# [/DEF:CommitSchema:Class]
# [DEF:BranchCreate:Class]
# @PURPOSE: Schema for branch creation requests.
class BranchCreate(BaseModel):
"""Schema for branch creation requests."""
name: str
from_branch: str
# [/DEF:BranchCreate:Class]
# [DEF:BranchCheckout:Class]
# @PURPOSE: Schema for branch checkout requests.
class BranchCheckout(BaseModel):
"""Schema for branch checkout requests."""
name: str
# [/DEF:BranchCheckout:Class]
# [DEF:CommitCreate:Class]
# @PURPOSE: Schema for staging and committing changes.
class CommitCreate(BaseModel):
"""Schema for staging and committing changes."""
message: str
files: List[str]
# [/DEF:CommitCreate:Class]
# [DEF:ConflictResolution:Class]
# @PURPOSE: Schema for resolving merge conflicts.
class ConflictResolution(BaseModel):
"""Schema for resolving merge conflicts."""
file_path: str
resolution: str = Field(pattern="^(mine|theirs|manual)$")
content: Optional[str] = None
# [/DEF:ConflictResolution:Class]
# [DEF:DeploymentEnvironmentSchema:Class]
# @PURPOSE: Schema for representing a target deployment environment.
class DeploymentEnvironmentSchema(BaseModel):
"""Schema for representing a target deployment environment."""
id: str
name: str
superset_url: str
is_active: bool
class Config:
from_attributes = True
# [/DEF:DeploymentEnvironmentSchema:Class]
# [DEF:DeployRequest:Class]
# @PURPOSE: Schema for dashboard deployment requests.
class DeployRequest(BaseModel):
"""Schema for deployment requests."""
environment_id: str
# [/DEF:DeployRequest:Class]
# [DEF:RepoInitRequest:Class]
# @PURPOSE: Schema for repository initialization requests.
class RepoInitRequest(BaseModel):
"""Schema for repository initialization requests."""
config_id: str
remote_url: str
# [/DEF:RepoInitRequest:Class]
# [/DEF:backend.src.api.routes.git_schemas:Module]

View File

@@ -13,9 +13,10 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
from backend.src.dependencies import get_config_manager from ...core.logger import belief_scope
from backend.src.core.database import get_db from ...dependencies import get_config_manager
from backend.src.models.mapping import DatabaseMapping from ...core.database import get_db
from ...models.mapping import DatabaseMapping
from pydantic import BaseModel from pydantic import BaseModel
# [/SECTION] # [/SECTION]

View File

@@ -7,10 +7,10 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict from typing import List, Dict
from backend.src.dependencies import get_config_manager, get_task_manager from ...dependencies import get_config_manager, get_task_manager
from backend.src.models.dashboard import DashboardMetadata, DashboardSelection from ...models.dashboard import DashboardMetadata, DashboardSelection
from backend.src.core.superset_client import SupersetClient from ...core.superset_client import SupersetClient
from superset_tool.models import SupersetConfig from ...core.logger import belief_scope
router = APIRouter(prefix="/api", tags=["migration"]) router = APIRouter(prefix="/api", tags=["migration"])
@@ -22,19 +22,13 @@ router = APIRouter(prefix="/api", tags=["migration"])
# @RETURN: List[DashboardMetadata] # @RETURN: List[DashboardMetadata]
@router.get("/environments/{env_id}/dashboards", response_model=List[DashboardMetadata]) @router.get("/environments/{env_id}/dashboards", response_model=List[DashboardMetadata])
async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)): async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)):
environments = config_manager.get_environments() with belief_scope("get_dashboards", f"env_id={env_id}"):
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None) env = next((e for e in environments if e.id == env_id), None)
if not env: if not env:
raise HTTPException(status_code=404, detail="Environment not found") raise HTTPException(status_code=404, detail="Environment not found")
config = SupersetConfig( client = SupersetClient(env)
env=env.name,
base_url=env.url,
auth={'provider': 'db', 'username': env.username, 'password': env.password, 'refresh': False},
verify_ssl=True,
timeout=30
)
client = SupersetClient(config)
dashboards = client.get_dashboards_summary() dashboards = client.get_dashboards_summary()
return dashboards return dashboards
# [/DEF:get_dashboards:Function] # [/DEF:get_dashboards:Function]
@@ -47,8 +41,9 @@ async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)
# @RETURN: Dict - {"task_id": str, "message": str} # @RETURN: Dict - {"task_id": str, "message": str}
@router.post("/migration/execute") @router.post("/migration/execute")
async def execute_migration(selection: DashboardSelection, config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager)): async def execute_migration(selection: DashboardSelection, config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager)):
# Validate environments exist with belief_scope("execute_migration"):
environments = config_manager.get_environments() # Validate environments exist
environments = config_manager.get_environments()
env_ids = {e.id for e in environments} env_ids = {e.id for e in environments}
if selection.source_env_id not in env_ids or selection.target_env_id not in env_ids: if selection.source_env_id not in env_ids or selection.target_env_id not in env_ids:
raise HTTPException(status_code=400, detail="Invalid source or target environment") raise HTTPException(status_code=400, detail="Invalid source or target environment")

View File

@@ -13,11 +13,11 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import List from typing import List
from ...core.config_models import AppConfig, Environment, GlobalSettings from ...core.config_models import AppConfig, Environment, GlobalSettings
from ...models.storage import StorageConfig
from ...dependencies import get_config_manager from ...dependencies import get_config_manager
from ...core.config_manager import ConfigManager from ...core.config_manager import ConfigManager
from ...core.logger import logger, belief_scope from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient from ...core.superset_client import SupersetClient
from superset_tool.models import SupersetConfig
import os import os
# [/SECTION] # [/SECTION]
@@ -28,7 +28,7 @@ router = APIRouter()
# @PRE: Config manager is available. # @PRE: Config manager is available.
# @POST: Returns masked AppConfig. # @POST: Returns masked AppConfig.
# @RETURN: AppConfig - The current configuration. # @RETURN: AppConfig - The current configuration.
@router.get("/", response_model=AppConfig) @router.get("", response_model=AppConfig)
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)): async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
with belief_scope("get_settings"): with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings") logger.info("[get_settings][Entry] Fetching all settings")
@@ -53,10 +53,38 @@ async def update_global_settings(
): ):
with belief_scope("update_global_settings"): with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating global settings") logger.info("[update_global_settings][Entry] Updating global settings")
config_manager.update_global_settings(settings) config_manager.update_global_settings(settings)
return settings return settings
# [/DEF:update_global_settings:Function] # [/DEF:update_global_settings:Function]
# [DEF:get_storage_settings:Function]
# @PURPOSE: Retrieves storage-specific settings.
# @RETURN: StorageConfig - The storage configuration.
@router.get("/storage", response_model=StorageConfig)
async def get_storage_settings(config_manager: ConfigManager = Depends(get_config_manager)):
with belief_scope("get_storage_settings"):
return config_manager.get_config().settings.storage
# [/DEF:get_storage_settings:Function]
# [DEF:update_storage_settings:Function]
# @PURPOSE: Updates storage-specific settings.
# @PARAM: storage (StorageConfig) - The new storage settings.
# @POST: Storage settings are updated and saved.
# @RETURN: StorageConfig - The updated storage settings.
@router.put("/storage", response_model=StorageConfig)
async def update_storage_settings(storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager)):
with belief_scope("update_storage_settings"):
is_valid, message = config_manager.validate_path(storage.root_path)
if not is_valid:
raise HTTPException(status_code=400, detail=message)
settings = config_manager.get_config().settings
settings.storage = storage
config_manager.update_global_settings(settings)
return config_manager.get_config().settings.storage
# [/DEF:update_storage_settings:Function]
# [DEF:get_environments:Function] # [DEF:get_environments:Function]
# @PURPOSE: Lists all configured Superset environments. # @PURPOSE: Lists all configured Superset environments.
# @PRE: Config manager is available. # @PRE: Config manager is available.
@@ -85,17 +113,7 @@ async def add_environment(
# Validate connection before adding # Validate connection before adding
try: try:
superset_config = SupersetConfig( client = SupersetClient(env)
env=env.name,
base_url=env.url,
auth={
"provider": "db",
"username": env.username,
"password": env.password,
"refresh": "true"
}
)
client = SupersetClient(config=superset_config)
client.get_dashboards(query={"page_size": 1}) client.get_dashboards(query={"page_size": 1})
except Exception as e: except Exception as e:
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}") logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
@@ -130,17 +148,7 @@ async def update_environment(
# Validate connection before updating # Validate connection before updating
try: try:
superset_config = SupersetConfig( client = SupersetClient(env_to_validate)
env=env_to_validate.name,
base_url=env_to_validate.url,
auth={
"provider": "db",
"username": env_to_validate.username,
"password": env_to_validate.password,
"refresh": "true"
}
)
client = SupersetClient(config=superset_config)
client.get_dashboards(query={"page_size": 1}) client.get_dashboards(query={"page_size": 1})
except Exception as e: except Exception as e:
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}") logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
@@ -187,21 +195,8 @@ async def test_environment_connection(
raise HTTPException(status_code=404, detail=f"Environment {id} not found") raise HTTPException(status_code=404, detail=f"Environment {id} not found")
try: try:
# Create SupersetConfig
# Note: SupersetConfig expects 'auth' dict with specific keys
superset_config = SupersetConfig(
env=env.name,
base_url=env.url,
auth={
"provider": "db", # Defaulting to db for now
"username": env.username,
"password": env.password,
"refresh": "true"
}
)
# Initialize client (this will trigger authentication) # Initialize client (this will trigger authentication)
client = SupersetClient(config=superset_config) client = SupersetClient(env)
# Try a simple request to verify # Try a simple request to verify
client.get_dashboards(query={"page_size": 1}) client.get_dashboards(query={"page_size": 1})
@@ -213,30 +208,5 @@ async def test_environment_connection(
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
# [/DEF:test_environment_connection:Function] # [/DEF:test_environment_connection:Function]
# [DEF:validate_backup_path:Function]
# @PURPOSE: Validates if a backup path exists and is writable.
# @PRE: Path is provided in path_data.
# @POST: Returns success or error status.
# @PARAM: path (str) - The path to validate.
# @RETURN: dict - Validation result.
@router.post("/validate-path")
async def validate_backup_path(
path_data: dict,
config_manager: ConfigManager = Depends(get_config_manager)
):
with belief_scope("validate_backup_path"):
path = path_data.get("path")
if not path:
raise HTTPException(status_code=400, detail="Path is required")
logger.info(f"[validate_backup_path][Entry] Validating path: {path}")
valid, message = config_manager.validate_path(path)
if not valid:
return {"status": "error", "message": message}
return {"status": "success", "message": message}
# [/DEF:validate_backup_path:Function]
# [/DEF:SettingsRouter:Module] # [/DEF:SettingsRouter:Module]

View File

@@ -0,0 +1,132 @@
# [DEF:storage_routes:Module]
#
# @SEMANTICS: storage, files, upload, download, backup, repository
# @PURPOSE: API endpoints for file storage management (backups and repositories).
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.models.storage
#
# @INVARIANT: All paths must be validated against path traversal.
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse
from typing import List, Optional
from ...models.storage import StoredFile, FileCategory
from ...dependencies import get_plugin_loader
from ...plugins.storage.plugin import StoragePlugin
from ...core.logger import belief_scope
# [/SECTION]
router = APIRouter(tags=["storage"])
# [DEF:list_files:Function]
# @PURPOSE: List all files and directories in the storage system.
#
# @PRE: None.
# @POST: Returns a list of StoredFile objects.
#
# @PARAM: category (Optional[FileCategory]) - Filter by category.
# @PARAM: path (Optional[str]) - Subpath within the category.
# @RETURN: List[StoredFile] - List of files/directories.
#
# @RELATION: CALLS -> StoragePlugin.list_files
@router.get("/files", response_model=List[StoredFile])
async def list_files(
category: Optional[FileCategory] = None,
path: Optional[str] = None,
plugin_loader=Depends(get_plugin_loader)
):
with belief_scope("list_files"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
return storage_plugin.list_files(category, path)
# [/DEF:list_files:Function]
# [DEF:upload_file:Function]
# @PURPOSE: Upload a file to the storage system.
#
# @PRE: category must be a valid FileCategory.
# @PRE: file must be a valid UploadFile.
# @POST: Returns the StoredFile object of the uploaded file.
#
# @PARAM: category (FileCategory) - Target category.
# @PARAM: path (Optional[str]) - Target subpath.
# @PARAM: file (UploadFile) - The file content.
# @RETURN: StoredFile - Metadata of the uploaded file.
#
# @SIDE_EFFECT: Writes file to the filesystem.
#
# @RELATION: CALLS -> StoragePlugin.save_file
@router.post("/upload", response_model=StoredFile, status_code=201)
async def upload_file(
category: FileCategory = Form(...),
path: Optional[str] = Form(None),
file: UploadFile = File(...),
plugin_loader=Depends(get_plugin_loader)
):
with belief_scope("upload_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
try:
return await storage_plugin.save_file(file, category, path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:upload_file:Function]
# [DEF:delete_file:Function]
# @PURPOSE: Delete a specific file or directory.
#
# @PRE: category must be a valid FileCategory.
# @POST: Item is removed from storage.
#
# @PARAM: category (FileCategory) - File category.
# @PARAM: path (str) - Relative path of the item.
# @RETURN: None
#
# @SIDE_EFFECT: Deletes item from the filesystem.
#
# @RELATION: CALLS -> StoragePlugin.delete_file
@router.delete("/files/{category}/{path:path}", status_code=204)
async def delete_file(category: FileCategory, path: str, plugin_loader=Depends(get_plugin_loader)):
with belief_scope("delete_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
try:
storage_plugin.delete_file(category, path)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:delete_file:Function]
# [DEF:download_file:Function]
# @PURPOSE: Retrieve a file for download.
#
# @PRE: category must be a valid FileCategory.
# @POST: Returns a FileResponse.
#
# @PARAM: category (FileCategory) - File category.
# @PARAM: path (str) - Relative path of the file.
# @RETURN: FileResponse - The file content.
#
# @RELATION: CALLS -> StoragePlugin.get_file_path
@router.get("/download/{category}/{path:path}")
async def download_file(category: FileCategory, path: str, plugin_loader=Depends(get_plugin_loader)):
with belief_scope("download_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
try:
abs_path = storage_plugin.get_file_path(category, path)
filename = Path(path).name
return FileResponse(path=abs_path, filename=filename)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:download_file:Function]
# [/DEF:storage_routes:Module]

View File

@@ -6,10 +6,8 @@
import sys import sys
from pathlib import Path from pathlib import Path
# Add project root to sys.path to allow importing superset_tool # project_root is used for static files mounting
# Assuming app.py is in backend/src/
project_root = Path(__file__).resolve().parent.parent.parent project_root = Path(__file__).resolve().parent.parent.parent
sys.path.append(str(project_root))
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -20,7 +18,7 @@ import os
from .dependencies import get_task_manager, get_scheduler_service from .dependencies import get_task_manager, get_scheduler_service
from .core.logger import logger, belief_scope from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage
from .core.database import init_db from .core.database import init_db
# [DEF:App:Global] # [DEF:App:Global]
@@ -90,6 +88,8 @@ app.include_router(connections.router, prefix="/api/settings/connections", tags=
app.include_router(environments.router, prefix="/api/environments", tags=["Environments"]) app.include_router(environments.router, prefix="/api/environments", tags=["Environments"])
app.include_router(mappings.router) app.include_router(mappings.router)
app.include_router(migration.router) app.include_router(migration.router)
app.include_router(git.router)
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
# [DEF:websocket_endpoint:Function] # [DEF:websocket_endpoint:Function]
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task. # @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task.
@@ -167,7 +167,8 @@ if frontend_path.exists():
with belief_scope("serve_spa", f"path={file_path}"): with belief_scope("serve_spa", f"path={file_path}"):
# Don't serve SPA for API routes that fell through # Don't serve SPA for API routes that fell through
if file_path.startswith("api/"): if file_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API endpoint not found") logger.info(f"[DEBUG] API route fell through to serve_spa: {file_path}")
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
full_path = frontend_path / file_path full_path = frontend_path / file_path
if full_path.is_file(): if full_path.is_file():

View File

@@ -62,14 +62,18 @@ class ConfigManager:
logger.info(f"[_load_config][Action] Config file not found. Creating default.") logger.info(f"[_load_config][Action] Config file not found. Creating default.")
default_config = AppConfig( default_config = AppConfig(
environments=[], environments=[],
settings=GlobalSettings(backup_path="backups") settings=GlobalSettings()
) )
self._save_config_to_disk(default_config) self._save_config_to_disk(default_config)
return default_config return default_config
try: try:
with open(self.config_path, "r") as f: with open(self.config_path, "r") as f:
data = json.load(f) data = json.load(f)
# Check for deprecated field
if "settings" in data and "backup_path" in data["settings"]:
del data["settings"]["backup_path"]
config = AppConfig(**data) config = AppConfig(**data)
logger.info(f"[_load_config][Coherence:OK] Configuration loaded") logger.info(f"[_load_config][Coherence:OK] Configuration loaded")
return config return config
@@ -79,7 +83,7 @@ class ConfigManager:
# For now, return default to be safe, but log the error prominently. # For now, return default to be safe, but log the error prominently.
return AppConfig( return AppConfig(
environments=[], environments=[],
settings=GlobalSettings(backup_path="backups") settings=GlobalSettings(storage=StorageConfig())
) )
# [/DEF:_load_config:Function] # [/DEF:_load_config:Function]
@@ -186,6 +190,20 @@ class ConfigManager:
return len(self.config.environments) > 0 return len(self.config.environments) > 0
# [/DEF:has_environments:Function] # [/DEF:has_environments:Function]
# [DEF:get_environment:Function]
# @PURPOSE: Returns a single environment by ID.
# @PRE: self.config is set and isinstance(env_id, str) and len(env_id) > 0.
# @POST: Returns Environment object if found, None otherwise.
# @PARAM: env_id (str) - The ID of the environment to retrieve.
# @RETURN: Optional[Environment] - The environment with the given ID, or None.
def get_environment(self, env_id: str) -> Optional[Environment]:
with belief_scope("get_environment"):
for env in self.config.environments:
if env.id == env_id:
return env
return None
# [/DEF:get_environment:Function]
# [DEF:add_environment:Function] # [DEF:add_environment:Function]
# @PURPOSE: Adds a new environment to the configuration. # @PURPOSE: Adds a new environment to the configuration.
# @PRE: isinstance(env, Environment) # @PRE: isinstance(env, Environment)

View File

@@ -7,6 +7,7 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional from typing import List, Optional
from ..models.storage import StorageConfig
# [DEF:Schedule:DataClass] # [DEF:Schedule:DataClass]
# @PURPOSE: Represents a backup schedule configuration. # @PURPOSE: Represents a backup schedule configuration.
@@ -23,6 +24,8 @@ class Environment(BaseModel):
url: str url: str
username: str username: str
password: str # Will be masked in UI password: str # Will be masked in UI
verify_ssl: bool = True
timeout: int = 30
is_default: bool = False is_default: bool = False
backup_schedule: Schedule = Field(default_factory=Schedule) backup_schedule: Schedule = Field(default_factory=Schedule)
# [/DEF:Environment:DataClass] # [/DEF:Environment:DataClass]
@@ -40,7 +43,7 @@ class LoggingConfig(BaseModel):
# [DEF:GlobalSettings:DataClass] # [DEF:GlobalSettings:DataClass]
# @PURPOSE: Represents global application settings. # @PURPOSE: Represents global application settings.
class GlobalSettings(BaseModel): class GlobalSettings(BaseModel):
backup_path: str storage: StorageConfig = Field(default_factory=StorageConfig)
default_environment_id: Optional[str] = None default_environment_id: Optional[str] = None
logging: LoggingConfig = Field(default_factory=LoggingConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig)

View File

@@ -15,6 +15,7 @@ from ..models.mapping import Base
# Import models to ensure they're registered with Base # Import models to ensure they're registered with Base
from ..models.task import TaskRecord from ..models.task import TaskRecord
from ..models.connection import ConnectionConfig from ..models.connection import ConnectionConfig
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
from .logger import belief_scope from .logger import belief_scope
import os import os
# [/SECTION] # [/SECTION]

View File

@@ -28,12 +28,12 @@ class BeliefFormatter(logging.Formatter):
# @POST: Returns formatted string. # @POST: Returns formatted string.
# @PARAM: record (logging.LogRecord) - The log record to format. # @PARAM: record (logging.LogRecord) - The log record to format.
# @RETURN: str - The formatted log message. # @RETURN: str - The formatted log message.
# @SEMANTICS: logging, formatter, context
def format(self, record): def format(self, record):
msg = super().format(record)
anchor_id = getattr(_belief_state, 'anchor_id', None) anchor_id = getattr(_belief_state, 'anchor_id', None)
if anchor_id: if anchor_id:
msg = f"[{anchor_id}][Action] {msg}" record.msg = f"[{anchor_id}][Action] {record.msg}"
return msg return super().format(record)
# [/DEF:format:Function] # [/DEF:format:Function]
# [/DEF:BeliefFormatter:Class] # [/DEF:BeliefFormatter:Class]
@@ -55,6 +55,7 @@ class LogEntry(BaseModel):
# @PARAM: message (str) - Optional entry message. # @PARAM: message (str) - Optional entry message.
# @PRE: anchor_id must be provided. # @PRE: anchor_id must be provided.
# @POST: Thread-local belief state is updated and entry/exit logs are generated. # @POST: Thread-local belief state is updated and entry/exit logs are generated.
# @SEMANTICS: logging, context, belief_state
@contextmanager @contextmanager
def belief_scope(anchor_id: str, message: str = ""): def belief_scope(anchor_id: str, message: str = ""):
# Log Entry if enabled # Log Entry if enabled
@@ -89,6 +90,7 @@ def belief_scope(anchor_id: str, message: str = ""):
# @PRE: config is a valid LoggingConfig instance. # @PRE: config is a valid LoggingConfig instance.
# @POST: Logger level, handlers, and belief state flag are updated. # @POST: Logger level, handlers, and belief state flag are updated.
# @PARAM: config (LoggingConfig) - The logging configuration. # @PARAM: config (LoggingConfig) - The logging configuration.
# @SEMANTICS: logging, configuration, initialization
def configure_logger(config): def configure_logger(config):
global _enable_belief_state global _enable_belief_state
_enable_belief_state = config.enable_belief_state _enable_belief_state = config.enable_belief_state
@@ -141,6 +143,7 @@ class WebSocketLogHandler(logging.Handler):
# @PRE: capacity is an integer. # @PRE: capacity is an integer.
# @POST: Instance initialized with empty deque. # @POST: Instance initialized with empty deque.
# @PARAM: capacity (int) - Maximum number of logs to keep in memory. # @PARAM: capacity (int) - Maximum number of logs to keep in memory.
# @SEMANTICS: logging, initialization, buffer
def __init__(self, capacity: int = 1000): def __init__(self, capacity: int = 1000):
super().__init__() super().__init__()
self.log_buffer: deque[LogEntry] = deque(maxlen=capacity) self.log_buffer: deque[LogEntry] = deque(maxlen=capacity)
@@ -153,6 +156,7 @@ class WebSocketLogHandler(logging.Handler):
# @PRE: record is a logging.LogRecord. # @PRE: record is a logging.LogRecord.
# @POST: Log is added to the log_buffer. # @POST: Log is added to the log_buffer.
# @PARAM: record (logging.LogRecord) - The log record to emit. # @PARAM: record (logging.LogRecord) - The log record to emit.
# @SEMANTICS: logging, handler, buffer
def emit(self, record: logging.LogRecord): def emit(self, record: logging.LogRecord):
try: try:
log_entry = LogEntry( log_entry = LogEntry(
@@ -180,6 +184,7 @@ class WebSocketLogHandler(logging.Handler):
# @PRE: None. # @PRE: None.
# @POST: Returns list of LogEntry objects. # @POST: Returns list of LogEntry objects.
# @RETURN: List[LogEntry] - List of buffered log entries. # @RETURN: List[LogEntry] - List of buffered log entries.
# @SEMANTICS: logging, buffer, retrieval
def get_recent_logs(self) -> List[LogEntry]: def get_recent_logs(self) -> List[LogEntry]:
""" """
Returns a list of recent log entries from the buffer. Returns a list of recent log entries from the buffer.
@@ -193,6 +198,30 @@ class WebSocketLogHandler(logging.Handler):
# @SEMANTICS: logger, global, instance # @SEMANTICS: logger, global, instance
# @PURPOSE: The global logger instance for the application, configured with both a console handler and the custom WebSocket handler. # @PURPOSE: The global logger instance for the application, configured with both a console handler and the custom WebSocket handler.
logger = logging.getLogger("superset_tools_app") logger = logging.getLogger("superset_tools_app")
# [DEF:believed:Function]
# @PURPOSE: A decorator that wraps a function in a belief scope.
# @PARAM: anchor_id (str) - The identifier for the semantic block.
# @PRE: anchor_id must be a string.
# @POST: Returns a decorator function.
def believed(anchor_id: str):
# [DEF:decorator:Function]
# @PURPOSE: Internal decorator for belief scope.
# @PRE: func must be a callable.
# @POST: Returns the wrapped function.
def decorator(func):
# [DEF:wrapper:Function]
# @PURPOSE: Internal wrapper that enters belief scope.
# @PRE: None.
# @POST: Executes the function within a belief scope.
def wrapper(*args, **kwargs):
with belief_scope(anchor_id):
return func(*args, **kwargs)
# [/DEF:wrapper:Function]
return wrapper
# [/DEF:decorator:Function]
return decorator
# [/DEF:believed:Function]
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
# Create a formatter # Create a formatter

View File

@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, Any from typing import Dict, Any, Optional
from .logger import belief_scope from .logger import belief_scope
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -68,6 +68,21 @@ class PluginBase(ABC):
pass pass
# [/DEF:version:Function] # [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.
# @PRE: Plugin instance exists.
# @POST: Returns string route or None.
# @RETURN: Optional[str] - Frontend route.
def ui_route(self) -> Optional[str]:
"""
The frontend route for the plugin's UI.
Returns None if the plugin does not have a dedicated UI page.
"""
with belief_scope("ui_route"):
return None
# [/DEF:ui_route:Function]
@abstractmethod @abstractmethod
# [DEF:get_schema:Function] # [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the plugin's input parameters. # @PURPOSE: Returns the JSON schema for the plugin's input parameters.
@@ -111,5 +126,6 @@ class PluginConfig(BaseModel):
name: str = Field(..., description="Human-readable name for the plugin") name: str = Field(..., description="Human-readable name for the plugin")
description: str = Field(..., description="Brief description of what the plugin does") description: str = Field(..., description="Brief description of what the plugin does")
version: str = Field(..., description="Version of the plugin") version: str = Field(..., description="Version of the plugin")
ui_route: Optional[str] = Field(None, description="Frontend route for the plugin UI")
input_schema: Dict[str, Any] = Field(..., description="JSON schema for input parameters", alias="schema") input_schema: Dict[str, Any] = Field(..., description="JSON schema for input parameters", alias="schema")
# [/DEF:PluginConfig:Class] # [/DEF:PluginConfig:Class]

View File

@@ -50,9 +50,18 @@ class PluginLoader:
sys.path.insert(0, plugin_parent_dir) sys.path.insert(0, plugin_parent_dir)
for filename in os.listdir(self.plugin_dir): for filename in os.listdir(self.plugin_dir):
file_path = os.path.join(self.plugin_dir, filename)
# Handle directory-based plugins (packages)
if os.path.isdir(file_path):
init_file = os.path.join(file_path, "__init__.py")
if os.path.exists(init_file):
self._load_module(filename, init_file)
continue
# Handle single-file plugins
if filename.endswith(".py") and filename != "__init__.py": if filename.endswith(".py") and filename != "__init__.py":
module_name = filename[:-3] module_name = filename[:-3]
file_path = os.path.join(self.plugin_dir, filename)
self._load_module(module_name, file_path) self._load_module(module_name, file_path)
# [/DEF:_load_plugins:Function] # [/DEF:_load_plugins:Function]
@@ -132,6 +141,7 @@ class PluginLoader:
name=plugin_instance.name, name=plugin_instance.name,
description=plugin_instance.description, description=plugin_instance.description,
version=plugin_instance.version, version=plugin_instance.version,
ui_route=plugin_instance.ui_route,
schema=schema, schema=schema,
) )
# The following line is commented out because it requires a schema to be passed to validate against. # The following line is commented out because it requires a schema to be passed to validate against.

View File

@@ -1,74 +1,108 @@
# [DEF:backend.src.core.superset_client:Module] # [DEF:backend.src.core.superset_client:Module]
# #
# @SEMANTICS: superset, api, client, database, metadata # @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export
# @PURPOSE: Extends the base SupersetClient with database-specific metadata fetching. # @PURPOSE: Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию.
# @LAYER: Core # @LAYER: Core
# @RELATION: INHERITS_FROM -> superset_tool.client.SupersetClient # @RELATION: USES -> backend.src.core.utils.network.APIClient
# @RELATION: USES -> backend.src.core.config_models.Environment
# #
# @INVARIANT: All database metadata requests must include UUID and name. # @INVARIANT: All network operations must use the internal APIClient instance.
# @PUBLIC_API: SupersetClient
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from typing import List, Dict, Optional, Tuple import json
from backend.src.core.logger import belief_scope import zipfile
from superset_tool.client import SupersetClient as BaseSupersetClient from pathlib import Path
from superset_tool.models import SupersetConfig from typing import Any, Dict, List, Optional, Tuple, Union, cast
from requests import Response
from .logger import logger as app_logger, belief_scope
from .utils.network import APIClient, SupersetAPIError, AuthenticationError, DashboardNotFoundError, NetworkError
from .utils.fileio import get_filename_from_headers
from .config_models import Environment
# [/SECTION] # [/SECTION]
# [DEF:SupersetClient:Class] # [DEF:SupersetClient:Class]
# @PURPOSE: Extended SupersetClient for migration-specific operations. # @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
class SupersetClient(BaseSupersetClient): class SupersetClient:
# [DEF:__init__:Function]
# [DEF:get_databases_summary:Function] # @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
# @PURPOSE: Fetch a summary of databases including uuid, name, and engine. # @PRE: `env` должен быть валидным объектом Environment.
# @PRE: self.network must be initialized and authenticated. # @POST: Атрибуты `env` и `network` созданы и готовы к работе.
# @POST: Returns a list of database dictionaries with 'engine' field. # @PARAM: env (Environment) - Конфигурация окружения.
# @RETURN: List[Dict] - Summary of databases. def __init__(self, env: Environment):
def get_databases_summary(self) -> List[Dict]: with belief_scope("__init__"):
with belief_scope("SupersetClient.get_databases_summary"): app_logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient for env %s.", env.name)
""" self.env = env
Fetch a summary of databases including uuid, name, and engine. # Construct auth payload expected by Superset API
""" auth_payload = {
query = { "username": env.username,
"columns": ["uuid", "database_name", "backend"] "password": env.password,
} "provider": "db",
_, databases = self.get_databases(query=query) "refresh": "true"
}
# Map 'backend' to 'engine' for consistency with contracts self.network = APIClient(
for db in databases: config={
db['engine'] = db.pop('backend', None) "base_url": env.url,
"auth": auth_payload
return databases },
# [/DEF:get_databases_summary:Function] verify_ssl=env.verify_ssl,
timeout=env.timeout
)
self.delete_before_reimport: bool = False
app_logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.")
# [/DEF:__init__:Function]
# [DEF:get_database_by_uuid:Function] # [DEF:authenticate:Function]
# @PURPOSE: Find a database by its UUID. # @PURPOSE: Authenticates the client using the configured credentials.
# @PRE: db_uuid must be a string. # @PRE: self.network must be initialized with valid auth configuration.
# @POST: Returns database metadata if found. # @POST: Client is authenticated and tokens are stored.
# @PARAM: db_uuid (str) - The UUID of the database. # @RETURN: Dict[str, str] - Authentication tokens.
# @RETURN: Optional[Dict] - Database info if found, else None. def authenticate(self) -> Dict[str, str]:
def get_database_by_uuid(self, db_uuid: str) -> Optional[Dict]: with belief_scope("SupersetClient.authenticate"):
with belief_scope("SupersetClient.get_database_by_uuid", f"uuid={db_uuid}"): return self.network.authenticate()
""" # [/DEF:authenticate:Function]
Find a database by its UUID.
""" @property
query = { # [DEF:headers:Function]
"filters": [{"col": "uuid", "op": "eq", "value": db_uuid}] # @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
} # @PRE: APIClient is initialized and authenticated.
_, databases = self.get_databases(query=query) # @POST: Returns a dictionary of HTTP headers.
return databases[0] if databases else None def headers(self) -> dict:
# [/DEF:get_database_by_uuid:Function] with belief_scope("headers"):
return self.network.headers
# [/DEF:headers:Function]
# [SECTION: DASHBOARD OPERATIONS]
# [DEF:get_dashboards:Function]
# @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса для API.
# @PRE: Client is authenticated.
# @POST: Returns a tuple with total count and list of dashboards.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов).
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_dashboards"):
app_logger.info("[get_dashboards][Enter] Fetching dashboards.")
validated_query = self._validate_query_params(query or {})
if 'columns' not in validated_query:
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
paginated_data = self._fetch_all_pages(
endpoint="/dashboard/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
app_logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
return total_count, paginated_data
# [/DEF:get_dashboards:Function]
# [DEF:get_dashboards_summary:Function] # [DEF:get_dashboards_summary:Function]
# @PURPOSE: Fetches dashboard metadata optimized for the grid. # @PURPOSE: Fetches dashboard metadata optimized for the grid.
# @PRE: self.network must be authenticated. # @PRE: Client is authenticated.
# @POST: Returns a list of dashboard dictionaries mapped to the grid schema. # @POST: Returns a list of dashboard metadata summaries.
# @RETURN: List[Dict] # @RETURN: List[Dict]
def get_dashboards_summary(self) -> List[Dict]: def get_dashboards_summary(self) -> List[Dict]:
with belief_scope("SupersetClient.get_dashboards_summary"): with belief_scope("SupersetClient.get_dashboards_summary"):
"""
Fetches dashboard metadata optimized for the grid.
Returns a list of dictionaries mapped to DashboardMetadata fields.
"""
query = { query = {
"columns": ["id", "dashboard_title", "changed_on_utc", "published"] "columns": ["id", "dashboard_title", "changed_on_utc", "published"]
} }
@@ -86,34 +120,331 @@ class SupersetClient(BaseSupersetClient):
return result return result
# [/DEF:get_dashboards_summary:Function] # [/DEF:get_dashboards_summary:Function]
# [DEF:export_dashboard:Function]
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
# @PARAM: dashboard_id (int) - ID дашборда для экспорта.
# @PRE: dashboard_id must exist in Superset.
# @POST: Returns ZIP content and filename.
# @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла.
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
with belief_scope("export_dashboard"):
app_logger.info("[export_dashboard][Enter] Exporting dashboard %s.", dashboard_id)
response = self.network.request(
method="GET",
endpoint="/dashboard/export/",
params={"q": json.dumps([dashboard_id])},
stream=True,
raw_response=True,
)
response = cast(Response, response)
self._validate_export_response(response, dashboard_id)
filename = self._resolve_export_filename(response, dashboard_id)
app_logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename)
return response.content, filename
# [/DEF:export_dashboard:Function]
# [DEF:import_dashboard:Function]
# @PURPOSE: Импортирует дашборд из ZIP-файла.
# @PARAM: file_name (Union[str, Path]) - Путь к ZIP-архиву.
# @PARAM: dash_id (Optional[int]) - ID дашборда для удаления при сбое.
# @PARAM: dash_slug (Optional[str]) - Slug дашборда для поиска ID.
# @PRE: file_name must be a valid ZIP dashboard export.
# @POST: Dashboard is imported or re-imported after deletion.
# @RETURN: Dict - Ответ API в случае успеха.
def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict:
with belief_scope("import_dashboard"):
file_path = str(file_name)
self._validate_import_file(file_path)
try:
return self._do_import(file_path)
except Exception as exc:
app_logger.error("[import_dashboard][Failure] First import attempt failed: %s", exc, exc_info=True)
if not self.delete_before_reimport:
raise
target_id = self._resolve_target_id_for_delete(dash_id, dash_slug)
if target_id is None:
app_logger.error("[import_dashboard][Failure] No ID available for delete-retry.")
raise
self.delete_dashboard(target_id)
app_logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id)
return self._do_import(file_path)
# [/DEF:import_dashboard:Function]
# [DEF:delete_dashboard:Function]
# @PURPOSE: Удаляет дашборд по его ID или slug.
# @PARAM: dashboard_id (Union[int, str]) - ID или slug дашборда.
# @PRE: dashboard_id must exist.
# @POST: Dashboard is removed from Superset.
def delete_dashboard(self, dashboard_id: Union[int, str]) -> None:
with belief_scope("delete_dashboard"):
app_logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", dashboard_id)
response = self.network.request(method="DELETE", endpoint=f"/dashboard/{dashboard_id}")
response = cast(Dict, response)
if response.get("result", True) is not False:
app_logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id)
else:
app_logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response)
# [/DEF:delete_dashboard:Function]
# [/SECTION]
# [SECTION: DATASET OPERATIONS]
# [DEF:get_datasets:Function]
# @PURPOSE: Получает полный список датасетов, автоматически обрабатывая пагинацию.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @PRE: Client is authenticated.
# @POST: Returns total count and list of datasets.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов).
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_datasets"):
app_logger.info("[get_datasets][Enter] Fetching datasets.")
validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dataset/")
paginated_data = self._fetch_all_pages(
endpoint="/dataset/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
app_logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
return total_count, paginated_data
# [/DEF:get_datasets:Function]
# [DEF:get_dataset:Function] # [DEF:get_dataset:Function]
# @PURPOSE: Fetch full dataset structure including columns and metrics. # @PURPOSE: Получает информацию о конкретном датасете по его ID.
# @PRE: dataset_id must be a valid integer. # @PARAM: dataset_id (int) - ID датасета.
# @POST: Returns full dataset metadata from Superset API. # @PRE: dataset_id must exist.
# @PARAM: dataset_id (int) - The ID of the dataset. # @POST: Returns dataset details.
# @RETURN: Dict - The dataset metadata. # @RETURN: Dict - Информация о датасете.
def get_dataset(self, dataset_id: int) -> Dict: def get_dataset(self, dataset_id: int) -> Dict:
with belief_scope("SupersetClient.get_dataset", f"id={dataset_id}"): with belief_scope("SupersetClient.get_dataset", f"id={dataset_id}"):
""" app_logger.info("[get_dataset][Enter] Fetching dataset %s.", dataset_id)
Fetch full dataset structure. response = self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}")
""" response = cast(Dict, response)
return self.network.get(f"/api/v1/dataset/{dataset_id}").json() app_logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id)
return response
# [/DEF:get_dataset:Function] # [/DEF:get_dataset:Function]
# [DEF:update_dataset:Function] # [DEF:update_dataset:Function]
# @PURPOSE: Update dataset metadata. # @PURPOSE: Обновляет данные датасета по его ID.
# @PRE: dataset_id must be valid, data must be a valid Superset dataset payload. # @PARAM: dataset_id (int) - ID датасета.
# @PARAM: data (Dict) - Данные для обновления.
# @PRE: dataset_id must exist.
# @POST: Dataset is updated in Superset. # @POST: Dataset is updated in Superset.
# @PARAM: dataset_id (int) - The ID of the dataset. # @RETURN: Dict - Ответ API.
# @PARAM: data (Dict) - The payload for update. def update_dataset(self, dataset_id: int, data: Dict) -> Dict:
def update_dataset(self, dataset_id: int, data: Dict):
with belief_scope("SupersetClient.update_dataset", f"id={dataset_id}"): with belief_scope("SupersetClient.update_dataset", f"id={dataset_id}"):
""" app_logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id)
Update dataset metadata. response = self.network.request(
""" method="PUT",
self.network.put(f"/api/v1/dataset/{dataset_id}", json=data) endpoint=f"/dataset/{dataset_id}",
data=json.dumps(data),
headers={'Content-Type': 'application/json'}
)
response = cast(Dict, response)
app_logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id)
return response
# [/DEF:update_dataset:Function] # [/DEF:update_dataset:Function]
# [/SECTION]
# [SECTION: DATABASE OPERATIONS]
# [DEF:get_databases:Function]
# @PURPOSE: Получает полный список баз данных.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @PRE: Client is authenticated.
# @POST: Returns total count and list of databases.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список баз данных).
def get_databases(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_databases"):
app_logger.info("[get_databases][Enter] Fetching databases.")
validated_query = self._validate_query_params(query or {})
if 'columns' not in validated_query:
validated_query['columns'] = []
total_count = self._fetch_total_object_count(endpoint="/database/")
paginated_data = self._fetch_all_pages(
endpoint="/database/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
app_logger.info("[get_databases][Exit] Found %d databases.", total_count)
return total_count, paginated_data
# [/DEF:get_databases:Function]
# [DEF:get_database:Function]
# @PURPOSE: Получает информацию о конкретной базе данных по её ID.
# @PARAM: database_id (int) - ID базы данных.
# @PRE: database_id must exist.
# @POST: Returns database details.
# @RETURN: Dict - Информация о базе данных.
def get_database(self, database_id: int) -> Dict:
with belief_scope("get_database"):
app_logger.info("[get_database][Enter] Fetching database %s.", database_id)
response = self.network.request(method="GET", endpoint=f"/database/{database_id}")
response = cast(Dict, response)
app_logger.info("[get_database][Exit] Got database %s.", database_id)
return response
# [/DEF:get_database:Function]
# [DEF:get_databases_summary:Function]
# @PURPOSE: Fetch a summary of databases including uuid, name, and engine.
# @PRE: Client is authenticated.
# @POST: Returns list of database summaries.
# @RETURN: List[Dict] - Summary of databases.
def get_databases_summary(self) -> List[Dict]:
with belief_scope("SupersetClient.get_databases_summary"):
query = {
"columns": ["uuid", "database_name", "backend"]
}
_, databases = self.get_databases(query=query)
# Map 'backend' to 'engine' for consistency with contracts
for db in databases:
db['engine'] = db.pop('backend', None)
return databases
# [/DEF:get_databases_summary:Function]
# [DEF:get_database_by_uuid:Function]
# @PURPOSE: Find a database by its UUID.
# @PARAM: db_uuid (str) - The UUID of the database.
# @PRE: db_uuid must be a valid UUID string.
# @POST: Returns database info or None.
# @RETURN: Optional[Dict] - Database info if found, else None.
def get_database_by_uuid(self, db_uuid: str) -> Optional[Dict]:
with belief_scope("SupersetClient.get_database_by_uuid", f"uuid={db_uuid}"):
query = {
"filters": [{"col": "uuid", "op": "eq", "value": db_uuid}]
}
_, databases = self.get_databases(query=query)
return databases[0] if databases else None
# [/DEF:get_database_by_uuid:Function]
# [/SECTION]
# [SECTION: HELPERS]
# [DEF:_resolve_target_id_for_delete:Function]
# @PURPOSE: Resolves a dashboard ID from either an ID or a slug.
# @PRE: Either dash_id or dash_slug should be provided.
# @POST: Returns the resolved ID or None.
def _resolve_target_id_for_delete(self, dash_id: Optional[int], dash_slug: Optional[str]) -> Optional[int]:
with belief_scope("_resolve_target_id_for_delete"):
if dash_id is not None:
return dash_id
if dash_slug is not None:
app_logger.debug("[_resolve_target_id_for_delete][State] Resolving ID by slug '%s'.", dash_slug)
try:
_, candidates = self.get_dashboards(query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]})
if candidates:
target_id = candidates[0]["id"]
app_logger.debug("[_resolve_target_id_for_delete][Success] Resolved slug to ID %s.", target_id)
return target_id
except Exception as e:
app_logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e)
return None
# [/DEF:_resolve_target_id_for_delete:Function]
# [DEF:_do_import:Function]
# @PURPOSE: Performs the actual multipart upload for import.
# @PRE: file_name must be a path to an existing ZIP file.
# @POST: Returns the API response from the upload.
def _do_import(self, file_name: Union[str, Path]) -> Dict:
with belief_scope("_do_import"):
app_logger.debug(f"[_do_import][State] Uploading file: {file_name}")
file_path = Path(file_name)
if not file_path.exists():
app_logger.error(f"[_do_import][Failure] File does not exist: {file_name}")
raise FileNotFoundError(f"File does not exist: {file_name}")
return self.network.upload_file(
endpoint="/dashboard/import/",
file_info={"file_obj": file_path, "file_name": file_path.name, "form_field": "formData"},
extra_data={"overwrite": "true"},
timeout=self.env.timeout * 2,
)
# [/DEF:_do_import:Function]
# [DEF:_validate_export_response:Function]
# @PURPOSE: Validates that the export response is a non-empty ZIP archive.
# @PRE: response must be a valid requests.Response object.
# @POST: Raises SupersetAPIError if validation fails.
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
with belief_scope("_validate_export_response"):
content_type = response.headers.get("Content-Type", "")
if "application/zip" not in content_type:
raise SupersetAPIError(f"Получен не ZIP-архив (Content-Type: {content_type})")
if not response.content:
raise SupersetAPIError("Получены пустые данные при экспорте")
# [/DEF:_validate_export_response:Function]
# [DEF:_resolve_export_filename:Function]
# @PURPOSE: Determines the filename for an exported dashboard.
# @PRE: response must contain Content-Disposition header or dashboard_id must be provided.
# @POST: Returns a sanitized filename string.
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
with belief_scope("_resolve_export_filename"):
filename = get_filename_from_headers(dict(response.headers))
if not filename:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
app_logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename)
return filename
# [/DEF:_resolve_export_filename:Function]
# [DEF:_validate_query_params:Function]
# @PURPOSE: Ensures query parameters have default page and page_size.
# @PRE: query can be None or a dictionary.
# @POST: Returns a dictionary with at least page and page_size.
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
with belief_scope("_validate_query_params"):
base_query = {"page": 0, "page_size": 1000}
return {**base_query, **(query or {})}
# [/DEF:_validate_query_params:Function]
# [DEF:_fetch_total_object_count:Function]
# @PURPOSE: Fetches the total number of items for a given endpoint.
# @PRE: endpoint must be a valid Superset API path.
# @POST: Returns the total count as an integer.
def _fetch_total_object_count(self, endpoint: str) -> int:
with belief_scope("_fetch_total_object_count"):
return self.network.fetch_paginated_count(
endpoint=endpoint,
query_params={"page": 0, "page_size": 1},
count_field="count",
)
# [/DEF:_fetch_total_object_count:Function]
# [DEF:_fetch_all_pages:Function]
# @PURPOSE: Iterates through all pages to collect all data items.
# @PRE: pagination_options must contain base_query, total_count, and results_field.
# @POST: Returns a combined list of all items.
def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]:
with belief_scope("_fetch_all_pages"):
return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options)
# [/DEF:_fetch_all_pages:Function]
# [DEF:_validate_import_file:Function]
# @PURPOSE: Validates that the file to be imported is a valid ZIP with metadata.yaml.
# @PRE: zip_path must be a path to a file.
# @POST: Raises error if file is missing, not a ZIP, or missing metadata.
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
with belief_scope("_validate_import_file"):
path = Path(zip_path)
if not path.exists():
raise FileNotFoundError(f"Файл {zip_path} не существует")
if not zipfile.is_zipfile(path):
raise SupersetAPIError(f"Файл {zip_path} не является ZIP-архивом")
with zipfile.ZipFile(path, "r") as zf:
if not any(n.endswith("metadata.yaml") for n in zf.namelist()):
raise SupersetAPIError(f"Архив {zip_path} не содержит 'metadata.yaml'")
# [/DEF:_validate_import_file:Function]
# [/SECTION]
# [/DEF:SupersetClient:Class] # [/DEF:SupersetClient:Class]
# [/DEF:backend.src.core.superset_client:Module] # [/DEF:backend.src.core.superset_client:Module]

View File

@@ -1,240 +1,237 @@
# [DEF:superset_tool.utils.dataset_mapper:Module] # [DEF:backend.core.utils.dataset_mapper:Module]
# #
# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset # @SEMANTICS: dataset, mapping, postgresql, xlsx, superset
# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов. # @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
# @LAYER: Domain # @LAYER: Domain
# @RELATION: DEPENDS_ON -> superset_tool.client # @RELATION: DEPENDS_ON -> backend.core.superset_client
# @RELATION: DEPENDS_ON -> pandas # @RELATION: DEPENDS_ON -> pandas
# @RELATION: DEPENDS_ON -> psycopg2 # @RELATION: DEPENDS_ON -> psycopg2
# @PUBLIC_API: DatasetMapper # @PUBLIC_API: DatasetMapper
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
import pandas as pd # type: ignore import pandas as pd # type: ignore
import psycopg2 # type: ignore import psycopg2 # type: ignore
from superset_tool.client import SupersetClient from typing import Dict, List, Optional, Any
from superset_tool.utils.init_clients import setup_clients from ..logger import logger as app_logger, belief_scope
from superset_tool.utils.logger import SupersetLogger # [/SECTION]
from typing import Dict, List, Optional, Any
# [/SECTION] # [DEF:DatasetMapper:Class]
# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset.
# [DEF:DatasetMapper:Class] class DatasetMapper:
# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset. # [DEF:__init__:Function]
class DatasetMapper: # @PURPOSE: Initializes the mapper.
# [DEF:__init__:Function] # @POST: Объект DatasetMapper инициализирован.
# @PURPOSE: Initializes the mapper. def __init__(self):
# @PRE: logger должен быть экземпляром SupersetLogger. pass
# @POST: Объект DatasetMapper инициализирован. # [/DEF:__init__:Function]
def __init__(self, logger: SupersetLogger):
self.logger = logger # [DEF:get_postgres_comments:Function]
# [/DEF:__init__:Function] # @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL.
# @PRE: db_config должен содержать валидные параметры подключения (host, port, user, password, dbname).
# [DEF:get_postgres_comments:Function] # @PRE: table_name и table_schema должны быть строками.
# @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL. # @POST: Возвращается словарь, где ключи - имена колонок, значения - комментарии из БД.
# @PRE: db_config должен содержать валидные параметры подключения (host, port, user, password, dbname). # @THROW: Exception - При ошибках подключения или выполнения запроса к БД.
# @PRE: table_name и table_schema должны быть строками. # @PARAM: db_config (Dict) - Конфигурация для подключения к БД.
# @POST: Возвращается словарь, где ключи - имена колонок, значения - комментарии из БД. # @PARAM: table_name (str) - Имя таблицы.
# @THROW: Exception - При ошибках подключения или выполнения запроса к БД. # @PARAM: table_schema (str) - Схема таблицы.
# @PARAM: db_config (Dict) - Конфигурация для подключения к БД. # @RETURN: Dict[str, str] - Словарь с комментариями к колонкам.
# @PARAM: table_name (str) - Имя таблицы. def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]:
# @PARAM: table_schema (str) - Схема таблицы. with belief_scope("Fetch comments from PostgreSQL"):
# @RETURN: Dict[str, str] - Словарь с комментариями к колонкам. app_logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name)
def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]: query = f"""
with self.logger.belief_scope("Fetch comments from PostgreSQL"): SELECT
self.logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name) cols.column_name,
query = f""" CASE
SELECT WHEN pg_catalog.col_description(
cols.column_name, (SELECT c.oid
CASE FROM pg_catalog.pg_class c
WHEN pg_catalog.col_description( JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
(SELECT c.oid WHERE c.relname = cols.table_name
FROM pg_catalog.pg_class c AND n.nspname = cols.table_schema),
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace cols.ordinal_position::int
WHERE c.relname = cols.table_name ) LIKE '%|%' THEN
AND n.nspname = cols.table_schema), split_part(
cols.ordinal_position::int pg_catalog.col_description(
) LIKE '%|%' THEN (SELECT c.oid
split_part( FROM pg_catalog.pg_class c
pg_catalog.col_description( JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
(SELECT c.oid WHERE c.relname = cols.table_name
FROM pg_catalog.pg_class c AND n.nspname = cols.table_schema),
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace cols.ordinal_position::int
WHERE c.relname = cols.table_name ),
AND n.nspname = cols.table_schema), '|',
cols.ordinal_position::int 1
), )
'|', ELSE
1 pg_catalog.col_description(
) (SELECT c.oid
ELSE FROM pg_catalog.pg_class c
pg_catalog.col_description( JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
(SELECT c.oid WHERE c.relname = cols.table_name
FROM pg_catalog.pg_class c AND n.nspname = cols.table_schema),
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace cols.ordinal_position::int
WHERE c.relname = cols.table_name )
AND n.nspname = cols.table_schema), END AS column_comment
cols.ordinal_position::int FROM
) information_schema.columns cols
END AS column_comment WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}';
FROM """
information_schema.columns cols comments = {}
WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}'; try:
""" with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor:
comments = {} cursor.execute(query)
try: for row in cursor.fetchall():
with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor: if row[1]:
cursor.execute(query) comments[row[0]] = row[1]
for row in cursor.fetchall(): app_logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments))
if row[1]: except Exception as e:
comments[row[0]] = row[1] app_logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True)
self.logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments)) raise
except Exception as e: return comments
self.logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True) # [/DEF:get_postgres_comments:Function]
raise
return comments # [DEF:load_excel_mappings:Function]
# [/DEF:get_postgres_comments:Function] # @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла.
# @PRE: file_path должен указывать на существующий XLSX файл.
# [DEF:load_excel_mappings:Function] # @POST: Возвращается словарь с меппингами из файла.
# @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла. # @THROW: Exception - При ошибках чтения файла или парсинга.
# @PRE: file_path должен указывать на существующий XLSX файл. # @PARAM: file_path (str) - Путь к XLSX файлу.
# @POST: Возвращается словарь с меппингами из файла. # @RETURN: Dict[str, str] - Словарь с меппингами.
# @THROW: Exception - При ошибках чтения файла или парсинга. def load_excel_mappings(self, file_path: str) -> Dict[str, str]:
# @PARAM: file_path (str) - Путь к XLSX файлу. with belief_scope("Load mappings from Excel"):
# @RETURN: Dict[str, str] - Словарь с меппингами. app_logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path)
def load_excel_mappings(self, file_path: str) -> Dict[str, str]: try:
with self.logger.belief_scope("Load mappings from Excel"): df = pd.read_excel(file_path)
self.logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path) mappings = df.set_index('column_name')['verbose_name'].to_dict()
try: app_logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings))
df = pd.read_excel(file_path) return mappings
mappings = df.set_index('column_name')['verbose_name'].to_dict() except Exception as e:
self.logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings)) app_logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True)
return mappings raise
except Exception as e: # [/DEF:load_excel_mappings:Function]
self.logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True)
raise # [DEF:run_mapping:Function]
# [/DEF:load_excel_mappings:Function] # @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset.
# @PRE: superset_client должен быть авторизован.
# [DEF:run_mapping:Function] # @PRE: dataset_id должен быть существующим ID в Superset.
# @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset. # @POST: Если найдены изменения, датасет в Superset обновлен через API.
# @PRE: superset_client должен быть авторизован. # @RELATION: CALLS -> self.get_postgres_comments
# @PRE: dataset_id должен быть существующим ID в Superset. # @RELATION: CALLS -> self.load_excel_mappings
# @POST: Если найдены изменения, датасет в Superset обновлен через API. # @RELATION: CALLS -> superset_client.get_dataset
# @RELATION: CALLS -> self.get_postgres_comments # @RELATION: CALLS -> superset_client.update_dataset
# @RELATION: CALLS -> self.load_excel_mappings # @PARAM: superset_client (Any) - Клиент Superset.
# @RELATION: CALLS -> superset_client.get_dataset # @PARAM: dataset_id (int) - ID датасета для обновления.
# @RELATION: CALLS -> superset_client.update_dataset # @PARAM: source (str) - Источник данных ('postgres', 'excel', 'both').
# @PARAM: superset_client (SupersetClient) - Клиент Superset. # @PARAM: postgres_config (Optional[Dict]) - Конфигурация для подключения к PostgreSQL.
# @PARAM: dataset_id (int) - ID датасета для обновления. # @PARAM: excel_path (Optional[str]) - Путь к XLSX файлу.
# @PARAM: source (str) - Источник данных ('postgres', 'excel', 'both'). # @PARAM: table_name (Optional[str]) - Имя таблицы в PostgreSQL.
# @PARAM: postgres_config (Optional[Dict]) - Конфигурация для подключения к PostgreSQL. # @PARAM: table_schema (Optional[str]) - Схема таблицы в PostgreSQL.
# @PARAM: excel_path (Optional[str]) - Путь к XLSX файлу. def run_mapping(self, superset_client: Any, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None):
# @PARAM: table_name (Optional[str]) - Имя таблицы в PostgreSQL. with belief_scope(f"Run dataset mapping for ID {dataset_id}"):
# @PARAM: table_schema (Optional[str]) - Схема таблицы в PostgreSQL. app_logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source)
def run_mapping(self, superset_client: SupersetClient, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None): mappings: Dict[str, str] = {}
with self.logger.belief_scope(f"Run dataset mapping for ID {dataset_id}"):
self.logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source) try:
mappings: Dict[str, str] = {} if source in ['postgres', 'both']:
assert postgres_config and table_name and table_schema, "Postgres config is required."
try: mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema))
if source in ['postgres', 'both']: if source in ['excel', 'both']:
assert postgres_config and table_name and table_schema, "Postgres config is required." assert excel_path, "Excel path is required."
mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema)) mappings.update(self.load_excel_mappings(excel_path))
if source in ['excel', 'both']: if source not in ['postgres', 'excel', 'both']:
assert excel_path, "Excel path is required." app_logger.error("[run_mapping][Failure] Invalid source: %s.", source)
mappings.update(self.load_excel_mappings(excel_path)) return
if source not in ['postgres', 'excel', 'both']:
self.logger.error("[run_mapping][Failure] Invalid source: %s.", source) dataset_response = superset_client.get_dataset(dataset_id)
return dataset_data = dataset_response['result']
dataset_response = superset_client.get_dataset(dataset_id) original_columns = dataset_data.get('columns', [])
dataset_data = dataset_response['result'] updated_columns = []
changes_made = False
original_columns = dataset_data.get('columns', [])
updated_columns = [] for column in original_columns:
changes_made = False col_name = column.get('column_name')
for column in original_columns: new_column = {
col_name = column.get('column_name') "column_name": col_name,
"id": column.get("id"),
new_column = { "advanced_data_type": column.get("advanced_data_type"),
"column_name": col_name, "description": column.get("description"),
"id": column.get("id"), "expression": column.get("expression"),
"advanced_data_type": column.get("advanced_data_type"), "extra": column.get("extra"),
"description": column.get("description"), "filterable": column.get("filterable"),
"expression": column.get("expression"), "groupby": column.get("groupby"),
"extra": column.get("extra"), "is_active": column.get("is_active"),
"filterable": column.get("filterable"), "is_dttm": column.get("is_dttm"),
"groupby": column.get("groupby"), "python_date_format": column.get("python_date_format"),
"is_active": column.get("is_active"), "type": column.get("type"),
"is_dttm": column.get("is_dttm"), "uuid": column.get("uuid"),
"python_date_format": column.get("python_date_format"), "verbose_name": column.get("verbose_name"),
"type": column.get("type"), }
"uuid": column.get("uuid"),
"verbose_name": column.get("verbose_name"), new_column = {k: v for k, v in new_column.items() if v is not None}
}
if col_name in mappings:
new_column = {k: v for k, v in new_column.items() if v is not None} mapping_value = mappings[col_name]
if isinstance(mapping_value, str) and new_column.get('verbose_name') != mapping_value:
if col_name in mappings: new_column['verbose_name'] = mapping_value
mapping_value = mappings[col_name] changes_made = True
if isinstance(mapping_value, str) and new_column.get('verbose_name') != mapping_value:
new_column['verbose_name'] = mapping_value updated_columns.append(new_column)
changes_made = True
updated_metrics = []
updated_columns.append(new_column) for metric in dataset_data.get("metrics", []):
new_metric = {
updated_metrics = [] "id": metric.get("id"),
for metric in dataset_data.get("metrics", []): "metric_name": metric.get("metric_name"),
new_metric = { "expression": metric.get("expression"),
"id": metric.get("id"), "verbose_name": metric.get("verbose_name"),
"metric_name": metric.get("metric_name"), "description": metric.get("description"),
"expression": metric.get("expression"), "d3format": metric.get("d3format"),
"verbose_name": metric.get("verbose_name"), "currency": metric.get("currency"),
"description": metric.get("description"), "extra": metric.get("extra"),
"d3format": metric.get("d3format"), "warning_text": metric.get("warning_text"),
"currency": metric.get("currency"), "metric_type": metric.get("metric_type"),
"extra": metric.get("extra"), "uuid": metric.get("uuid"),
"warning_text": metric.get("warning_text"), }
"metric_type": metric.get("metric_type"), updated_metrics.append({k: v for k, v in new_metric.items() if v is not None})
"uuid": metric.get("uuid"),
} if changes_made:
updated_metrics.append({k: v for k, v in new_metric.items() if v is not None}) payload_for_update = {
"database_id": dataset_data.get("database", {}).get("id"),
if changes_made: "table_name": dataset_data.get("table_name"),
payload_for_update = { "schema": dataset_data.get("schema"),
"database_id": dataset_data.get("database", {}).get("id"), "columns": updated_columns,
"table_name": dataset_data.get("table_name"), "owners": [owner["id"] for owner in dataset_data.get("owners", [])],
"schema": dataset_data.get("schema"), "metrics": updated_metrics,
"columns": updated_columns, "extra": dataset_data.get("extra"),
"owners": [owner["id"] for owner in dataset_data.get("owners", [])], "description": dataset_data.get("description"),
"metrics": updated_metrics, "sql": dataset_data.get("sql"),
"extra": dataset_data.get("extra"), "cache_timeout": dataset_data.get("cache_timeout"),
"description": dataset_data.get("description"), "catalog": dataset_data.get("catalog"),
"sql": dataset_data.get("sql"), "default_endpoint": dataset_data.get("default_endpoint"),
"cache_timeout": dataset_data.get("cache_timeout"), "external_url": dataset_data.get("external_url"),
"catalog": dataset_data.get("catalog"), "fetch_values_predicate": dataset_data.get("fetch_values_predicate"),
"default_endpoint": dataset_data.get("default_endpoint"), "filter_select_enabled": dataset_data.get("filter_select_enabled"),
"external_url": dataset_data.get("external_url"), "is_managed_externally": dataset_data.get("is_managed_externally"),
"fetch_values_predicate": dataset_data.get("fetch_values_predicate"), "is_sqllab_view": dataset_data.get("is_sqllab_view"),
"filter_select_enabled": dataset_data.get("filter_select_enabled"), "main_dttm_col": dataset_data.get("main_dttm_col"),
"is_managed_externally": dataset_data.get("is_managed_externally"), "normalize_columns": dataset_data.get("normalize_columns"),
"is_sqllab_view": dataset_data.get("is_sqllab_view"), "offset": dataset_data.get("offset"),
"main_dttm_col": dataset_data.get("main_dttm_col"), "template_params": dataset_data.get("template_params"),
"normalize_columns": dataset_data.get("normalize_columns"), }
"offset": dataset_data.get("offset"),
"template_params": dataset_data.get("template_params"), payload_for_update = {k: v for k, v in payload_for_update.items() if v is not None}
}
superset_client.update_dataset(dataset_id, payload_for_update)
payload_for_update = {k: v for k, v in payload_for_update.items() if v is not None} app_logger.info("[run_mapping][Success] Dataset %d columns' verbose_name updated.", dataset_id)
else:
superset_client.update_dataset(dataset_id, payload_for_update) app_logger.info("[run_mapping][State] No changes in columns' verbose_name, skipping update.")
self.logger.info("[run_mapping][Success] Dataset %d columns' verbose_name updated.", dataset_id)
else: except (AssertionError, FileNotFoundError, Exception) as e:
self.logger.info("[run_mapping][State] No changes in columns' verbose_name, skipping update.") app_logger.error("[run_mapping][Failure] %s", e, exc_info=True)
return
except (AssertionError, FileNotFoundError, Exception) as e: # [/DEF:run_mapping:Function]
self.logger.error("[run_mapping][Failure] %s", e, exc_info=True) # [/DEF:DatasetMapper:Class]
return
# [/DEF:run_mapping:Function] # [/DEF:backend.core.utils.dataset_mapper:Module]
# [/DEF:DatasetMapper:Class]
# [/DEF:superset_tool.utils.dataset_mapper:Module]

View File

@@ -1,507 +1,488 @@
# [DEF:superset_tool.utils.fileio:Module] # [DEF:backend.core.utils.fileio:Module]
# #
# @SEMANTICS: file, io, zip, yaml, temp, archive, utility # @SEMANTICS: file, io, zip, yaml, temp, archive, utility
# @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий. # @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
# @LAYER: Infra # @LAYER: Infra
# @RELATION: DEPENDS_ON -> superset_tool.exceptions # @RELATION: DEPENDS_ON -> backend.src.core.logger
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger # @RELATION: DEPENDS_ON -> pyyaml
# @RELATION: DEPENDS_ON -> pyyaml # @PUBLIC_API: create_temp_file, remove_empty_directories, read_dashboard_from_disk, calculate_crc32, RetentionPolicy, archive_exports, save_and_unpack_dashboard, update_yamls, create_dashboard_export, sanitize_filename, get_filename_from_headers, consolidate_archive_folders
# @PUBLIC_API: create_temp_file, remove_empty_directories, read_dashboard_from_disk, calculate_crc32, RetentionPolicy, archive_exports, save_and_unpack_dashboard, update_yamls, create_dashboard_export, sanitize_filename, get_filename_from_headers, consolidate_archive_folders
# [SECTION: IMPORTS]
# [SECTION: IMPORTS] import os
import os import re
import re import zipfile
import zipfile from pathlib import Path
from pathlib import Path from typing import Any, Optional, Tuple, Dict, List, Union, LiteralString, Generator
from typing import Any, Optional, Tuple, Dict, List, Union, LiteralString, Generator from contextlib import contextmanager
from contextlib import contextmanager import tempfile
import tempfile from datetime import date, datetime
from datetime import date, datetime import shutil
import glob import zlib
import shutil from dataclasses import dataclass
import zlib import yaml
from dataclasses import dataclass from ..logger import logger as app_logger, belief_scope
import yaml # [/SECTION]
from superset_tool.exceptions import InvalidZipFormatError
from superset_tool.utils.logger import SupersetLogger # [DEF:InvalidZipFormatError:Class]
# [/SECTION] # @PURPOSE: Exception raised when a file is not a valid ZIP archive.
class InvalidZipFormatError(Exception):
# [DEF:create_temp_file:Function] pass
# @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением. # [/DEF:InvalidZipFormatError:Class]
# @PRE: suffix должен быть строкой, определяющей тип ресурса.
# @POST: Временный ресурс создан и путь к нему возвращен; ресурс удален после выхода из контекста. # [DEF:create_temp_file:Function]
# @PARAM: content (Optional[bytes]) - Бинарное содержимое для записи во временный файл. # @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
# @PARAM: suffix (str) - Суффикс ресурса. Если `.dir`, создается директория. # @PRE: suffix должен быть строкой, определяющей тип ресурса.
# @PARAM: mode (str) - Режим записи в файл (e.g., 'wb'). # @POST: Временный ресурс создан и путь к нему возвращен; ресурс удален после выхода из контекста.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера. # @PARAM: content (Optional[bytes]) - Бинарное содержимое для записи во временный файл.
# @YIELDS: Path - Путь к временному ресурсу. # @PARAM: suffix (str) - Суффикс ресурса. Если `.dir`, создается директория.
# @THROW: IOError - При ошибках создания ресурса. # @PARAM: mode (str) - Режим записи в файл (e.g., 'wb').
@contextmanager # @YIELDS: Path - Путь к временному ресурсу.
def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', dry_run = False, logger: Optional[SupersetLogger] = None) -> Generator[Path, None, None]: # @THROW: IOError - При ошибках создания ресурса.
logger = logger or SupersetLogger(name="fileio") @contextmanager
with logger.belief_scope("Create temporary resource"): def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', dry_run = False) -> Generator[Path, None, None]:
resource_path = None with belief_scope("Create temporary resource"):
is_dir = suffix.startswith('.dir') resource_path = None
try: is_dir = suffix.startswith('.dir')
if is_dir: try:
with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir: if is_dir:
resource_path = Path(temp_dir) with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir:
logger.debug("[create_temp_file][State] Created temporary directory: %s", resource_path) resource_path = Path(temp_dir)
yield resource_path app_logger.debug("[create_temp_file][State] Created temporary directory: %s", resource_path)
else: yield resource_path
fd, temp_path_str = tempfile.mkstemp(suffix=suffix) else:
resource_path = Path(temp_path_str) fd, temp_path_str = tempfile.mkstemp(suffix=suffix)
os.close(fd) resource_path = Path(temp_path_str)
if content: os.close(fd)
resource_path.write_bytes(content) if content:
logger.debug("[create_temp_file][State] Created temporary file: %s", resource_path) resource_path.write_bytes(content)
yield resource_path app_logger.debug("[create_temp_file][State] Created temporary file: %s", resource_path)
finally: yield resource_path
if resource_path and resource_path.exists() and not dry_run: finally:
try: if resource_path and resource_path.exists() and not dry_run:
if resource_path.is_dir(): try:
shutil.rmtree(resource_path) if resource_path.is_dir():
logger.debug("[create_temp_file][Cleanup] Removed temporary directory: %s", resource_path) shutil.rmtree(resource_path)
else: app_logger.debug("[create_temp_file][Cleanup] Removed temporary directory: %s", resource_path)
resource_path.unlink() else:
logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path) resource_path.unlink()
except OSError as e: app_logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path)
logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e) except OSError as e:
# [/DEF:create_temp_file:Function] app_logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e)
# [/DEF:create_temp_file:Function]
# [DEF:remove_empty_directories:Function]
# @PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути. # [DEF:remove_empty_directories:Function]
# @PRE: root_dir должен быть путем к существующей директории. # @PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
# @POST: Все пустые поддиректории удалены, возвращено их количество. # @PRE: root_dir должен быть путем к существующей директории.
# @PARAM: root_dir (str) - Путь к корневой директории для очистки. # @POST: Все пустые поддиректории удалены, возвращено их количество.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера. # @PARAM: root_dir (str) - Путь к корневой директории для очистки.
# @RETURN: int - Количество удаленных директорий. # @RETURN: int - Количество удаленных директорий.
def remove_empty_directories(root_dir: str, logger: Optional[SupersetLogger] = None) -> int: def remove_empty_directories(root_dir: str) -> int:
logger = logger or SupersetLogger(name="fileio") with belief_scope(f"Remove empty directories in {root_dir}"):
with logger.belief_scope(f"Remove empty directories in {root_dir}"): app_logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir)
logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir) removed_count = 0
removed_count = 0 if not os.path.isdir(root_dir):
if not os.path.isdir(root_dir): app_logger.error("[remove_empty_directories][Failure] Directory not found: %s", root_dir)
logger.error("[remove_empty_directories][Failure] Directory not found: %s", root_dir) return 0
return 0 for current_dir, _, _ in os.walk(root_dir, topdown=False):
for current_dir, _, _ in os.walk(root_dir, topdown=False): if not os.listdir(current_dir):
if not os.listdir(current_dir): try:
try: os.rmdir(current_dir)
os.rmdir(current_dir) removed_count += 1
removed_count += 1 app_logger.info("[remove_empty_directories][State] Removed empty directory: %s", current_dir)
logger.info("[remove_empty_directories][State] Removed empty directory: %s", current_dir) except OSError as e:
except OSError as e: app_logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e)
logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e) app_logger.info("[remove_empty_directories][Exit] Removed %d empty directories.", removed_count)
logger.info("[remove_empty_directories][Exit] Removed %d empty directories.", removed_count) return removed_count
return removed_count # [/DEF:remove_empty_directories:Function]
# [/DEF:remove_empty_directories:Function]
# [DEF:read_dashboard_from_disk:Function]
# [DEF:read_dashboard_from_disk:Function] # @PURPOSE: Читает бинарное содержимое файла с диска.
# @PURPOSE: Читает бинарное содержимое файла с диска. # @PRE: file_path должен указывать на существующий файл.
# @PRE: file_path должен указывать на существующий файл. # @POST: Возвращает байты содержимого и имя файла.
# @POST: Возвращает байты содержимого и имя файла. # @PARAM: file_path (str) - Путь к файлу.
# @PARAM: file_path (str) - Путь к файлу. # @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла).
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера. # @THROW: FileNotFoundError - Если файл не найден.
# @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла). def read_dashboard_from_disk(file_path: str) -> Tuple[bytes, str]:
# @THROW: FileNotFoundError - Если файл не найден. with belief_scope(f"Read dashboard from {file_path}"):
def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]: path = Path(file_path)
logger = logger or SupersetLogger(name="fileio") assert path.is_file(), f"Файл дашборда не найден: {file_path}"
with logger.belief_scope(f"Read dashboard from {file_path}"): app_logger.info("[read_dashboard_from_disk][Enter] Reading file: %s", file_path)
path = Path(file_path) content = path.read_bytes()
assert path.is_file(), f"Файл дашборда не найден: {file_path}" if not content:
logger.info("[read_dashboard_from_disk][Enter] Reading file: %s", file_path) app_logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path)
content = path.read_bytes() return content, path.name
if not content: # [/DEF:read_dashboard_from_disk:Function]
logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path)
return content, path.name # [DEF:calculate_crc32:Function]
# [/DEF:read_dashboard_from_disk:Function] # @PURPOSE: Вычисляет контрольную сумму CRC32 для файла.
# @PRE: file_path должен быть объектом Path к существующему файлу.
# [DEF:calculate_crc32:Function] # @POST: Возвращает 8-значную hex-строку CRC32.
# @PURPOSE: Вычисляет контрольную сумму CRC32 для файла. # @PARAM: file_path (Path) - Путь к файлу.
# @PRE: file_path должен быть объектом Path к существующему файлу. # @RETURN: str - 8-значное шестнадцатеричное представление CRC32.
# @POST: Возвращает 8-значную hex-строку CRC32. # @THROW: IOError - При ошибках чтения файла.
# @PARAM: file_path (Path) - Путь к файлу. def calculate_crc32(file_path: Path) -> str:
# @RETURN: str - 8-значное шестнадцатеричное представление CRC32. with belief_scope(f"Calculate CRC32 for {file_path}"):
# @THROW: IOError - При ошибках чтения файла. with open(file_path, 'rb') as f:
def calculate_crc32(file_path: Path) -> str: crc32_value = zlib.crc32(f.read())
logger = SupersetLogger(name="fileio") return f"{crc32_value:08x}"
with logger.belief_scope(f"Calculate CRC32 for {file_path}"): # [/DEF:calculate_crc32:Function]
with open(file_path, 'rb') as f:
crc32_value = zlib.crc32(f.read()) # [SECTION: DATA_CLASSES]
return f"{crc32_value:08x}" # [DEF:RetentionPolicy:DataClass]
# [/DEF:calculate_crc32:Function] # @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
@dataclass
# [SECTION: DATA_CLASSES] class RetentionPolicy:
# [DEF:RetentionPolicy:DataClass] daily: int = 7
# @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные). weekly: int = 4
@dataclass monthly: int = 12
class RetentionPolicy: # [/DEF:RetentionPolicy:DataClass]
daily: int = 7 # [/SECTION]
weekly: int = 4
monthly: int = 12 # [DEF:archive_exports:Function]
# [/DEF:RetentionPolicy:DataClass] # @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
# [/SECTION] # @PRE: output_dir должен быть путем к существующей директории.
# @POST: Старые или дублирующиеся архивы удалены согласно политике.
# [DEF:archive_exports:Function] # @RELATION: CALLS -> apply_retention_policy
# @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию. # @RELATION: CALLS -> calculate_crc32
# @PRE: output_dir должен быть путем к существующей директории. # @PARAM: output_dir (str) - Директория с архивами.
# @POST: Старые или дублирующиеся архивы удалены согласно политике. # @PARAM: policy (RetentionPolicy) - Политика хранения.
# @RELATION: CALLS -> apply_retention_policy # @PARAM: deduplicate (bool) - Флаг для включения удаления дубликатов по CRC32.
# @RELATION: CALLS -> calculate_crc32 def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False) -> None:
# @PARAM: output_dir (str) - Директория с архивами. with belief_scope(f"Archive exports in {output_dir}"):
# @PARAM: policy (RetentionPolicy) - Политика хранения. output_path = Path(output_dir)
# @PARAM: deduplicate (bool) - Флаг для включения удаления дубликатов по CRC32. if not output_path.is_dir():
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера. app_logger.warning("[archive_exports][Skip] Archive directory not found: %s", output_dir)
def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False, logger: Optional[SupersetLogger] = None) -> None: return
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope(f"Archive exports in {output_dir}"): app_logger.info("[archive_exports][Enter] Managing archive in %s", output_dir)
output_path = Path(output_dir)
if not output_path.is_dir(): # 1. Collect all zip files
logger.warning("[archive_exports][Skip] Archive directory not found: %s", output_dir) zip_files = list(output_path.glob("*.zip"))
return if not zip_files:
app_logger.info("[archive_exports][State] No zip files found in %s", output_dir)
logger.info("[archive_exports][Enter] Managing archive in %s", output_dir) return
# 1. Collect all zip files # 2. Deduplication
zip_files = list(output_path.glob("*.zip")) if deduplicate:
if not zip_files: app_logger.info("[archive_exports][State] Starting deduplication...")
logger.info("[archive_exports][State] No zip files found in %s", output_dir) checksums = {}
return files_to_remove = []
# 2. Deduplication # Sort by modification time (newest first) to keep the latest version
if deduplicate: zip_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
logger.info("[archive_exports][State] Starting deduplication...")
checksums = {} for file_path in zip_files:
files_to_remove = [] try:
crc = calculate_crc32(file_path)
# Sort by modification time (newest first) to keep the latest version if crc in checksums:
zip_files.sort(key=lambda f: f.stat().st_mtime, reverse=True) files_to_remove.append(file_path)
app_logger.debug("[archive_exports][State] Duplicate found: %s (same as %s)", file_path.name, checksums[crc].name)
for file_path in zip_files: else:
try: checksums[crc] = file_path
crc = calculate_crc32(file_path) except Exception as e:
if crc in checksums: app_logger.error("[archive_exports][Failure] Failed to calculate CRC32 for %s: %s", file_path, e)
files_to_remove.append(file_path)
logger.debug("[archive_exports][State] Duplicate found: %s (same as %s)", file_path.name, checksums[crc].name) for f in files_to_remove:
else: try:
checksums[crc] = file_path f.unlink()
except Exception as e: zip_files.remove(f)
logger.error("[archive_exports][Failure] Failed to calculate CRC32 for %s: %s", file_path, e) app_logger.info("[archive_exports][State] Removed duplicate: %s", f.name)
except OSError as e:
for f in files_to_remove: app_logger.error("[archive_exports][Failure] Failed to remove duplicate %s: %s", f, e)
try:
f.unlink() # 3. Retention Policy
zip_files.remove(f) files_with_dates = []
logger.info("[archive_exports][State] Removed duplicate: %s", f.name) for file_path in zip_files:
except OSError as e: # Try to extract date from filename
logger.error("[archive_exports][Failure] Failed to remove duplicate %s: %s", f, e) # Pattern: ..._YYYYMMDD_HHMMSS.zip or ..._YYYYMMDD.zip
match = re.search(r'_(\d{8})_', file_path.name)
# 3. Retention Policy file_date = None
files_with_dates = [] if match:
for file_path in zip_files: try:
# Try to extract date from filename date_str = match.group(1)
# Pattern: ..._YYYYMMDD_HHMMSS.zip or ..._YYYYMMDD.zip file_date = datetime.strptime(date_str, "%Y%m%d").date()
match = re.search(r'_(\d{8})_', file_path.name) except ValueError:
file_date = None pass
if match:
try: if not file_date:
date_str = match.group(1) # Fallback to modification time
file_date = datetime.strptime(date_str, "%Y%m%d").date() file_date = datetime.fromtimestamp(file_path.stat().st_mtime).date()
except ValueError:
pass files_with_dates.append((file_path, file_date))
if not file_date: files_to_keep = apply_retention_policy(files_with_dates, policy)
# Fallback to modification time
file_date = datetime.fromtimestamp(file_path.stat().st_mtime).date() for file_path, _ in files_with_dates:
if file_path not in files_to_keep:
files_with_dates.append((file_path, file_date)) try:
file_path.unlink()
files_to_keep = apply_retention_policy(files_with_dates, policy, logger) app_logger.info("[archive_exports][State] Removed by retention policy: %s", file_path.name)
except OSError as e:
for file_path, _ in files_with_dates: app_logger.error("[archive_exports][Failure] Failed to remove %s: %s", file_path, e)
if file_path not in files_to_keep: # [/DEF:archive_exports:Function]
try:
file_path.unlink() # [DEF:apply_retention_policy:Function]
logger.info("[archive_exports][State] Removed by retention policy: %s", file_path.name) # @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
except OSError as e: # @PRE: files_with_dates is a list of (Path, date) tuples.
logger.error("[archive_exports][Failure] Failed to remove %s: %s", file_path, e) # @POST: Returns a set of files to keep.
# [/DEF:archive_exports:Function] # @PARAM: files_with_dates (List[Tuple[Path, date]]) - Список файлов с датами.
# @PARAM: policy (RetentionPolicy) - Политика хранения.
# [DEF:apply_retention_policy:Function] # @RETURN: set - Множество путей к файлам, которые должны быть сохранены.
# @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить. def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy) -> set:
# @PRE: files_with_dates is a list of (Path, date) tuples. with belief_scope("Apply retention policy"):
# @POST: Returns a set of files to keep. # Сортируем по дате (от новой к старой)
# @PARAM: files_with_dates (List[Tuple[Path, date]]) - Список файлов с датами. sorted_files = sorted(files_with_dates, key=lambda x: x[1], reverse=True)
# @PARAM: policy (RetentionPolicy) - Политика хранения. # Словарь для хранения файлов по категориям
# @PARAM: logger (SupersetLogger) - Логгер. daily_files = []
# @RETURN: set - Множество путей к файлам, которые должны быть сохранены. weekly_files = []
def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy, logger: SupersetLogger) -> set: monthly_files = []
with logger.belief_scope("Apply retention policy"): today = date.today()
# Сортируем по дате (от новой к старой) for file_path, file_date in sorted_files:
sorted_files = sorted(files_with_dates, key=lambda x: x[1], reverse=True) # Ежедневные
# Словарь для хранения файлов по категориям if (today - file_date).days < policy.daily:
daily_files = [] daily_files.append(file_path)
weekly_files = [] # Еженедельные
monthly_files = [] elif (today - file_date).days < policy.weekly * 7:
today = date.today() weekly_files.append(file_path)
for file_path, file_date in sorted_files: # Ежемесячные
# Ежедневные elif (today - file_date).days < policy.monthly * 30:
if (today - file_date).days < policy.daily: monthly_files.append(file_path)
daily_files.append(file_path) # Возвращаем множество файлов, которые нужно сохранить
# Еженедельные files_to_keep = set()
elif (today - file_date).days < policy.weekly * 7: files_to_keep.update(daily_files)
weekly_files.append(file_path) files_to_keep.update(weekly_files[:policy.weekly])
# Ежемесячные files_to_keep.update(monthly_files[:policy.monthly])
elif (today - file_date).days < policy.monthly * 30: app_logger.debug("[apply_retention_policy][State] Keeping %d files according to retention policy", len(files_to_keep))
monthly_files.append(file_path) return files_to_keep
# Возвращаем множество файлов, которые нужно сохранить # [/DEF:apply_retention_policy:Function]
files_to_keep = set()
files_to_keep.update(daily_files) # [DEF:save_and_unpack_dashboard:Function]
files_to_keep.update(weekly_files[:policy.weekly]) # @PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
files_to_keep.update(monthly_files[:policy.monthly]) # @PRE: zip_content должен быть байтами валидного ZIP-архива.
logger.debug("[apply_retention_policy][State] Keeping %d files according to retention policy", len(files_to_keep)) # @POST: ZIP-файл сохранен, и если unpack=True, он распакован в output_dir.
return files_to_keep # @PARAM: zip_content (bytes) - Содержимое ZIP-архива.
# [/DEF:apply_retention_policy:Function] # @PARAM: output_dir (Union[str, Path]) - Директория для сохранения.
# @PARAM: unpack (bool) - Флаг, нужно ли распаковывать архив.
# [DEF:save_and_unpack_dashboard:Function] # @PARAM: original_filename (Optional[str]) - Исходное имя файла для сохранения.
# @PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его. # @RETURN: Tuple[Path, Optional[Path]] - Путь к ZIP-файлу и, если применимо, путь к директории с распаковкой.
# @PRE: zip_content должен быть байтами валидного ZIP-архива. # @THROW: InvalidZipFormatError - При ошибке формата ZIP.
# @POST: ZIP-файл сохранен, и если unpack=True, он распакован в output_dir. def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path], unpack: bool = False, original_filename: Optional[str] = None) -> Tuple[Path, Optional[Path]]:
# @PARAM: zip_content (bytes) - Содержимое ZIP-архива. with belief_scope("Save and unpack dashboard"):
# @PARAM: output_dir (Union[str, Path]) - Директория для сохранения. app_logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack)
# @PARAM: unpack (bool) - Флаг, нужно ли распаковывать архив. try:
# @PARAM: original_filename (Optional[str]) - Исходное имя файла для сохранения. output_path = Path(output_dir)
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера. output_path.mkdir(parents=True, exist_ok=True)
# @RETURN: Tuple[Path, Optional[Path]] - Путь к ZIP-файлу и, если применимо, путь к директории с распаковкой. zip_name = sanitize_filename(original_filename) if original_filename else f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
# @THROW: InvalidZipFormatError - При ошибке формата ZIP. zip_path = output_path / zip_name
def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path], unpack: bool = False, original_filename: Optional[str] = None, logger: Optional[SupersetLogger] = None) -> Tuple[Path, Optional[Path]]: zip_path.write_bytes(zip_content)
logger = logger or SupersetLogger(name="fileio") app_logger.info("[save_and_unpack_dashboard][State] Dashboard saved to: %s", zip_path)
with logger.belief_scope("Save and unpack dashboard"): if unpack:
logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack) with zipfile.ZipFile(zip_path, 'r') as zip_ref:
try: zip_ref.extractall(output_path)
output_path = Path(output_dir) app_logger.info("[save_and_unpack_dashboard][State] Dashboard unpacked to: %s", output_path)
output_path.mkdir(parents=True, exist_ok=True) return zip_path, output_path
zip_name = sanitize_filename(original_filename) if original_filename else f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip" return zip_path, None
zip_path = output_path / zip_name except zipfile.BadZipFile as e:
zip_path.write_bytes(zip_content) app_logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e)
logger.info("[save_and_unpack_dashboard][State] Dashboard saved to: %s", zip_path) raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e
if unpack: # [/DEF:save_and_unpack_dashboard:Function]
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(output_path) # [DEF:update_yamls:Function]
logger.info("[save_and_unpack_dashboard][State] Dashboard unpacked to: %s", output_path) # @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
return zip_path, output_path # @PRE: path должен быть существующей директорией.
return zip_path, None # @POST: Все YAML файлы в директории обновлены согласно переданным параметрам.
except zipfile.BadZipFile as e: # @RELATION: CALLS -> _update_yaml_file
logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e) # @THROW: FileNotFoundError - Если `path` не существует.
raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e # @PARAM: db_configs (Optional[List[Dict]]) - Список конфигураций для замены.
# [/DEF:save_and_unpack_dashboard:Function] # @PARAM: path (str) - Путь к директории с YAML файлами.
# @PARAM: regexp_pattern (Optional[LiteralString]) - Паттерн для поиска.
# [DEF:update_yamls:Function] # @PARAM: replace_string (Optional[LiteralString]) - Строка для замены.
# @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex. def update_yamls(db_configs: Optional[List[Dict[str, Any]]] = None, path: str = "dashboards", regexp_pattern: Optional[LiteralString] = None, replace_string: Optional[LiteralString] = None) -> None:
# @PRE: path должен быть существующей директорией. with belief_scope("Update YAML configurations"):
# @POST: Все YAML файлы в директории обновлены согласно переданным параметрам. app_logger.info("[update_yamls][Enter] Starting YAML configuration update.")
# @RELATION: CALLS -> _update_yaml_file dir_path = Path(path)
# @THROW: FileNotFoundError - Если `path` не существует. assert dir_path.is_dir(), f"Путь {path} не существует или не является директорией"
# @PARAM: db_configs (Optional[List[Dict]]) - Список конфигураций для замены.
# @PARAM: path (str) - Путь к директории с YAML файлами. configs: List[Dict[str, Any]] = db_configs or []
# @PARAM: regexp_pattern (Optional[LiteralString]) - Паттерн для поиска.
# @PARAM: replace_string (Optional[LiteralString]) - Строка для замены. for file_path in dir_path.rglob("*.yaml"):
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера. _update_yaml_file(file_path, configs, regexp_pattern, replace_string)
def update_yamls(db_configs: Optional[List[Dict[str, Any]]] = None, path: str = "dashboards", regexp_pattern: Optional[LiteralString] = None, replace_string: Optional[LiteralString] = None, logger: Optional[SupersetLogger] = None) -> None: # [/DEF:update_yamls:Function]
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope("Update YAML configurations"): # [DEF:_update_yaml_file:Function]
logger.info("[update_yamls][Enter] Starting YAML configuration update.") # @PURPOSE: (Helper) Обновляет один YAML файл.
dir_path = Path(path) # @PRE: file_path должен быть объектом Path к существующему YAML файлу.
assert dir_path.is_dir(), f"Путь {path} не существует или не является директорией" # @POST: Файл обновлен согласно переданным конфигурациям или регулярному выражению.
# @PARAM: file_path (Path) - Путь к файлу.
configs: List[Dict[str, Any]] = db_configs or [] # @PARAM: db_configs (List[Dict]) - Конфигурации.
# @PARAM: regexp_pattern (Optional[str]) - Паттерн.
for file_path in dir_path.rglob("*.yaml"): # @PARAM: replace_string (Optional[str]) - Замена.
_update_yaml_file(file_path, configs, regexp_pattern, replace_string, logger) def _update_yaml_file(file_path: Path, db_configs: List[Dict[str, Any]], regexp_pattern: Optional[str], replace_string: Optional[str]) -> None:
# [/DEF:update_yamls:Function] with belief_scope(f"Update YAML file: {file_path}"):
# Читаем содержимое файла
# [DEF:_update_yaml_file:Function] try:
# @PURPOSE: (Helper) Обновляет один YAML файл. with open(file_path, 'r', encoding='utf-8') as f:
# @PRE: file_path должен быть объектом Path к существующему YAML файлу. content = f.read()
# @POST: Файл обновлен согласно переданным конфигурациям или регулярному выражению. except Exception as e:
# @PARAM: file_path (Path) - Путь к файлу. app_logger.error("[_update_yaml_file][Failure] Failed to read %s: %s", file_path, e)
# @PARAM: db_configs (List[Dict]) - Конфигурации. return
# @PARAM: regexp_pattern (Optional[str]) - Паттерн. # Если задан pattern и replace_string, применяем замену по регулярному выражению
# @PARAM: replace_string (Optional[str]) - Замена. if regexp_pattern and replace_string:
# @PARAM: logger (SupersetLogger) - Логгер. try:
def _update_yaml_file(file_path: Path, db_configs: List[Dict[str, Any]], regexp_pattern: Optional[str], replace_string: Optional[str], logger: SupersetLogger) -> None: new_content = re.sub(regexp_pattern, replace_string, content)
with logger.belief_scope(f"Update YAML file: {file_path}"): if new_content != content:
# Читаем содержимое файла with open(file_path, 'w', encoding='utf-8') as f:
try: f.write(new_content)
with open(file_path, 'r', encoding='utf-8') as f: app_logger.info("[_update_yaml_file][State] Updated %s using regex pattern", file_path)
content = f.read() except Exception as e:
except Exception as e: app_logger.error("[_update_yaml_file][Failure] Error applying regex to %s: %s", file_path, e)
logger.error("[_update_yaml_file][Failure] Failed to read %s: %s", file_path, e) # Если заданы конфигурации, заменяем значения (поддержка old/new)
return if db_configs:
# Если задан pattern и replace_string, применяем замену по регулярному выражению try:
if regexp_pattern and replace_string: # Прямой текстовый заменитель для старых/новых значений, чтобы сохранить структуру файла
try: modified_content = content
new_content = re.sub(regexp_pattern, replace_string, content) for cfg in db_configs:
if new_content != content: # Ожидаем структуру: {'old': {...}, 'new': {...}}
with open(file_path, 'w', encoding='utf-8') as f: old_cfg = cfg.get('old', {})
f.write(new_content) new_cfg = cfg.get('new', {})
logger.info("[_update_yaml_file][State] Updated %s using regex pattern", file_path) for key, old_val in old_cfg.items():
except Exception as e: if key in new_cfg:
logger.error("[_update_yaml_file][Failure] Error applying regex to %s: %s", file_path, e) new_val = new_cfg[key]
# Если заданы конфигурации, заменяем значения (поддержка old/new) # Заменяем только точные совпадения старого значения в тексте YAML, используя ключ для контекста
if db_configs: if isinstance(old_val, str):
try: # Ищем паттерн: key: "value" или key: value
# Прямой текстовый заменитель для старых/новых значений, чтобы сохранить структуру файла key_pattern = re.escape(key)
modified_content = content val_pattern = re.escape(old_val)
for cfg in db_configs: # Группы: 1=ключ+разделитель, 2=открывающая кавычка (опц), 3=значение, 4=закрывающая кавычка (опц)
# Ожидаем структуру: {'old': {...}, 'new': {...}} pattern = rf'({key_pattern}\s*:\s*)(["\']?)({val_pattern})(["\']?)'
old_cfg = cfg.get('old', {})
new_cfg = cfg.get('new', {}) # [DEF:replacer:Function]
for key, old_val in old_cfg.items(): # @PURPOSE: Функция замены, сохраняющая кавычки если они были.
if key in new_cfg: # @PRE: match должен быть объектом совпадения регулярного выражения.
new_val = new_cfg[key] # @POST: Возвращает строку с новым значением, сохраняя префикс и кавычки.
# Заменяем только точные совпадения старого значения в тексте YAML, используя ключ для контекста def replacer(match):
if isinstance(old_val, str): prefix = match.group(1)
# Ищем паттерн: key: "value" или key: value quote_open = match.group(2)
key_pattern = re.escape(key) quote_close = match.group(4)
val_pattern = re.escape(old_val) return f"{prefix}{quote_open}{new_val}{quote_close}"
# Группы: 1=ключ+разделитель, 2=открывающая кавычка (опц), 3=значение, 4=закрывающая кавычка (опц) # [/DEF:replacer:Function]
pattern = rf'({key_pattern}\s*:\s*)(["\']?)({val_pattern})(["\']?)'
modified_content = re.sub(pattern, replacer, modified_content)
# [DEF:replacer:Function] app_logger.info("[_update_yaml_file][State] Replaced '%s' with '%s' for key %s in %s", old_val, new_val, key, file_path)
# @PURPOSE: Функция замены, сохраняющая кавычки если они были. # Записываем обратно изменённый контент без парсинга YAML, сохраняем оригинальное форматирование
# @PRE: match должен быть объектом совпадения регулярного выражения. with open(file_path, 'w', encoding='utf-8') as f:
# @POST: Возвращает строку с новым значением, сохраняя префикс и кавычки. f.write(modified_content)
def replacer(match): except Exception as e:
with logger.belief_scope("replacer"): app_logger.error("[_update_yaml_file][Failure] Error performing raw replacement in %s: %s", file_path, e)
prefix = match.group(1) # [/DEF:_update_yaml_file:Function]
quote_open = match.group(2)
quote_close = match.group(4) # [DEF:create_dashboard_export:Function]
return f"{prefix}{quote_open}{new_val}{quote_close}" # @PURPOSE: Создает ZIP-архив из указанных исходных путей.
# [/DEF:replacer:Function] # @PRE: source_paths должен содержать существующие пути.
# @POST: ZIP-архив создан по пути zip_path.
modified_content = re.sub(pattern, replacer, modified_content) # @PARAM: zip_path (Union[str, Path]) - Путь для сохранения ZIP архива.
logger.info("[_update_yaml_file][State] Replaced '%s' with '%s' for key %s in %s", old_val, new_val, key, file_path) # @PARAM: source_paths (List[Union[str, Path]]) - Список исходных путей для архивации.
# Записываем обратно изменённый контент без парсинга YAML, сохраняем оригинальное форматирование # @PARAM: exclude_extensions (Optional[List[str]]) - Список расширений для исключения.
with open(file_path, 'w', encoding='utf-8') as f: # @RETURN: bool - `True` при успехе, `False` при ошибке.
f.write(modified_content) def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union[str, Path]], exclude_extensions: Optional[List[str]] = None) -> bool:
except Exception as e: with belief_scope(f"Create dashboard export: {zip_path}"):
logger.error("[_update_yaml_file][Failure] Error performing raw replacement in %s: %s", file_path, e) app_logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path)
# [/DEF:_update_yaml_file:Function] try:
exclude_ext = [ext.lower() for ext in exclude_extensions or []]
# [DEF:create_dashboard_export:Function] with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# @PURPOSE: Создает ZIP-архив из указанных исходных путей. for src_path_str in source_paths:
# @PRE: source_paths должен содержать существующие пути. src_path = Path(src_path_str)
# @POST: ZIP-архив создан по пути zip_path. assert src_path.exists(), f"Путь не найден: {src_path}"
# @PARAM: zip_path (Union[str, Path]) - Путь для сохранения ZIP архива. for item in src_path.rglob('*'):
# @PARAM: source_paths (List[Union[str, Path]]) - Список исходных путей для архивации. if item.is_file() and item.suffix.lower() not in exclude_ext:
# @PARAM: exclude_extensions (Optional[List[str]]) - Список расширений для исключения. arcname = item.relative_to(src_path.parent)
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера. zipf.write(item, arcname)
# @RETURN: bool - `True` при успехе, `False` при ошибке. app_logger.info("[create_dashboard_export][Exit] Archive created: %s", zip_path)
def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union[str, Path]], exclude_extensions: Optional[List[str]] = None, logger: Optional[SupersetLogger] = None) -> bool: return True
logger = logger or SupersetLogger(name="fileio") except (IOError, zipfile.BadZipFile, AssertionError) as e:
with logger.belief_scope(f"Create dashboard export: {zip_path}"): app_logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True)
logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path) return False
try: # [/DEF:create_dashboard_export:Function]
exclude_ext = [ext.lower() for ext in exclude_extensions or []]
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: # [DEF:sanitize_filename:Function]
for src_path_str in source_paths: # @PURPOSE: Очищает строку от символов, недопустимых в именах файлов.
src_path = Path(src_path_str) # @PRE: filename должен быть строкой.
assert src_path.exists(), f"Путь не найден: {src_path}" # @POST: Возвращает строку без спецсимволов.
for item in src_path.rglob('*'): # @PARAM: filename (str) - Исходное имя файла.
if item.is_file() and item.suffix.lower() not in exclude_ext: # @RETURN: str - Очищенная строка.
arcname = item.relative_to(src_path.parent) def sanitize_filename(filename: str) -> str:
zipf.write(item, arcname) with belief_scope(f"Sanitize filename: {filename}"):
logger.info("[create_dashboard_export][Exit] Archive created: %s", zip_path) return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
return True # [/DEF:sanitize_filename:Function]
except (IOError, zipfile.BadZipFile, AssertionError) as e:
logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True) # [DEF:get_filename_from_headers:Function]
return False # @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
# [/DEF:create_dashboard_export:Function] # @PRE: headers должен быть словарем заголовков.
# @POST: Возвращает имя файла или None, если заголовок отсутствует.
# [DEF:sanitize_filename:Function] # @PARAM: headers (dict) - Словарь HTTP заголовков.
# @PURPOSE: Очищает строку от символов, недопустимых в именах файлов. # @RETURN: Optional[str] - Имя файла or `None`.
# @PRE: filename должен быть строкой. def get_filename_from_headers(headers: dict) -> Optional[str]:
# @POST: Возвращает строку без спецсимволов. with belief_scope("Get filename from headers"):
# @PARAM: filename (str) - Исходное имя файла. content_disposition = headers.get("Content-Disposition", "")
# @RETURN: str - Очищенная строка. if match := re.search(r'filename="?([^"]+)"?', content_disposition):
def sanitize_filename(filename: str) -> str: return match.group(1).strip()
logger = SupersetLogger(name="fileio") return None
with logger.belief_scope(f"Sanitize filename: {filename}"): # [/DEF:get_filename_from_headers:Function]
return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
# [/DEF:sanitize_filename:Function] # [DEF:consolidate_archive_folders:Function]
# @PURPOSE: Консолидирует директории архивов на основе общего слага в имени.
# [DEF:get_filename_from_headers:Function] # @PRE: root_directory должен быть объектом Path к существующей директории.
# @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'. # @POST: Директории с одинаковым префиксом объединены в одну.
# @PRE: headers должен быть словарем заголовков. # @THROW: TypeError, ValueError - Если `root_directory` невалиден.
# @POST: Возвращает имя файла или None, если заголовок отсутствует. # @PARAM: root_directory (Path) - Корневая директория для консолидации.
# @PARAM: headers (dict) - Словарь HTTP заголовков. def consolidate_archive_folders(root_directory: Path) -> None:
# @RETURN: Optional[str] - Имя файла or `None`. with belief_scope(f"Consolidate archives in {root_directory}"):
def get_filename_from_headers(headers: dict) -> Optional[str]: assert isinstance(root_directory, Path), "root_directory must be a Path object."
logger = SupersetLogger(name="fileio") assert root_directory.is_dir(), "root_directory must be an existing directory."
with logger.belief_scope("Get filename from headers"):
content_disposition = headers.get("Content-Disposition", "") app_logger.info("[consolidate_archive_folders][Enter] Consolidating archives in %s", root_directory)
if match := re.search(r'filename="?([^"]+)"?', content_disposition): # Собираем все директории с архивами
return match.group(1).strip() archive_dirs = []
return None for item in root_directory.iterdir():
# [/DEF:get_filename_from_headers:Function] if item.is_dir():
# Проверяем, есть ли в директории ZIP-архивы
# [DEF:consolidate_archive_folders:Function] if any(item.glob("*.zip")):
# @PURPOSE: Консолидирует директории архивов на основе общего слага в имени. archive_dirs.append(item)
# @PRE: root_directory должен быть объектом Path к существующей директории. # Группируем по слагу (части имени до первого '_')
# @POST: Директории с одинаковым префиксом объединены в одну. slug_groups = {}
# @THROW: TypeError, ValueError - Если `root_directory` невалиден. for dir_path in archive_dirs:
# @PARAM: root_directory (Path) - Корневая директория для консолидации. dir_name = dir_path.name
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера. slug = dir_name.split('_')[0] if '_' in dir_name else dir_name
def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None: if slug not in slug_groups:
logger = logger or SupersetLogger(name="fileio") slug_groups[slug] = []
with logger.belief_scope(f"Consolidate archives in {root_directory}"): slug_groups[slug].append(dir_path)
assert isinstance(root_directory, Path), "root_directory must be a Path object." # Для каждой группы консолидируем
assert root_directory.is_dir(), "root_directory must be an existing directory." for slug, dirs in slug_groups.items():
if len(dirs) <= 1:
logger.info("[consolidate_archive_folders][Enter] Consolidating archives in %s", root_directory) continue
# Собираем все директории с архивами # Создаем целевую директорию
archive_dirs = [] target_dir = root_directory / slug
for item in root_directory.iterdir(): target_dir.mkdir(exist_ok=True)
if item.is_dir(): app_logger.info("[consolidate_archive_folders][State] Consolidating %d directories under %s", len(dirs), target_dir)
# Проверяем, есть ли в директории ZIP-архивы # Перемещаем содержимое
if any(item.glob("*.zip")): for source_dir in dirs:
archive_dirs.append(item) if source_dir == target_dir:
# Группируем по слагу (части имени до первого '_') continue
slug_groups = {} for item in source_dir.iterdir():
for dir_path in archive_dirs: dest_item = target_dir / item.name
dir_name = dir_path.name try:
slug = dir_name.split('_')[0] if '_' in dir_name else dir_name if item.is_dir():
if slug not in slug_groups: shutil.move(str(item), str(dest_item))
slug_groups[slug] = [] else:
slug_groups[slug].append(dir_path) shutil.move(str(item), str(dest_item))
# Для каждой группы консолидируем except Exception as e:
for slug, dirs in slug_groups.items(): app_logger.error("[consolidate_archive_folders][Failure] Failed to move %s to %s: %s", item, dest_item, e)
if len(dirs) <= 1: # Удаляем исходную директорию
continue try:
# Создаем целевую директорию source_dir.rmdir()
target_dir = root_directory / slug app_logger.info("[consolidate_archive_folders][State] Removed source directory: %s", source_dir)
target_dir.mkdir(exist_ok=True) except Exception as e:
logger.info("[consolidate_archive_folders][State] Consolidating %d directories under %s", len(dirs), target_dir) app_logger.error("[consolidate_archive_folders][Failure] Failed to remove source directory %s: %s", source_dir, e)
# Перемещаем содержимое # [/DEF:consolidate_archive_folders:Function]
for source_dir in dirs:
if source_dir == target_dir: # [/DEF:backend.core.utils.fileio:Module]
continue
for item in source_dir.iterdir():
dest_item = target_dir / item.name
try:
if item.is_dir():
shutil.move(str(item), str(dest_item))
else:
shutil.move(str(item), str(dest_item))
except Exception as e:
logger.error("[consolidate_archive_folders][Failure] Failed to move %s to %s: %s", item, dest_item, e)
# Удаляем исходную директорию
try:
source_dir.rmdir()
logger.info("[consolidate_archive_folders][State] Removed source directory: %s", source_dir)
except Exception as e:
logger.error("[consolidate_archive_folders][Failure] Failed to remove source directory %s: %s", source_dir, e)
# [/DEF:consolidate_archive_folders:Function]
# [/DEF:superset_tool.utils.fileio:Module]

View File

@@ -1,265 +1,326 @@
# [DEF:superset_tool.utils.network:Module] # [DEF:backend.core.utils.network:Module]
# #
# @SEMANTICS: network, http, client, api, requests, session, authentication # @SEMANTICS: network, http, client, api, requests, session, authentication
# @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок. # @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
# @LAYER: Infra # @LAYER: Infra
# @RELATION: DEPENDS_ON -> superset_tool.exceptions # @RELATION: DEPENDS_ON -> backend.src.core.logger
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger # @RELATION: DEPENDS_ON -> requests
# @RELATION: DEPENDS_ON -> requests # @PUBLIC_API: APIClient
# @PUBLIC_API: APIClient
# [SECTION: IMPORTS]
# [SECTION: IMPORTS] from typing import Optional, Dict, Any, List, Union, cast
from typing import Optional, Dict, Any, List, Union, cast import json
import json import io
import io from pathlib import Path
from pathlib import Path import requests
import requests from requests.adapters import HTTPAdapter
from requests.adapters import HTTPAdapter import urllib3
import urllib3 from urllib3.util.retry import Retry
from superset_tool.utils.logger import belief_scope from ..logger import logger as app_logger, belief_scope
from urllib3.util.retry import Retry # [/SECTION]
from superset_tool.exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError
from superset_tool.utils.logger import SupersetLogger # [DEF:SupersetAPIError:Class]
# [/SECTION] # @PURPOSE: Base exception for all Superset API related errors.
class SupersetAPIError(Exception):
# [DEF:APIClient:Class] # [DEF:__init__:Function]
# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов. # @PURPOSE: Initializes the exception with a message and context.
class APIClient: # @PRE: message is a string, context is a dict.
DEFAULT_TIMEOUT = 30 # @POST: Exception is initialized with context.
def __init__(self, message: str = "Superset API error", **context: Any):
# [DEF:__init__:Function] with belief_scope("SupersetAPIError.__init__"):
# @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером. self.context = context
# @PARAM: config (Dict[str, Any]) - Конфигурация. super().__init__(f"[API_FAILURE] {message} | Context: {self.context}")
# @PARAM: verify_ssl (bool) - Проверять ли SSL. # [/DEF:__init__:Function]
# @PARAM: timeout (int) - Таймаут запросов. # [/DEF:SupersetAPIError:Class]
# @PARAM: logger (Optional[SupersetLogger]) - Логгер.
# @PRE: config must contain 'base_url' and 'auth'. # [DEF:AuthenticationError:Class]
# @POST: APIClient instance is initialized with a session. # @PURPOSE: Exception raised when authentication fails.
def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT, logger: Optional[SupersetLogger] = None): class AuthenticationError(SupersetAPIError):
with belief_scope("__init__"): # [DEF:__init__:Function]
self.logger = logger or SupersetLogger(name="APIClient") # @PURPOSE: Initializes the authentication error.
self.logger.info("[APIClient.__init__][Entry] Initializing APIClient.") # @PRE: message is a string, context is a dict.
self.base_url: str = config.get("base_url", "") # @POST: AuthenticationError is initialized.
self.auth = config.get("auth") def __init__(self, message: str = "Authentication failed", **context: Any):
self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout} with belief_scope("AuthenticationError.__init__"):
self.session = self._init_session() super().__init__(message, type="authentication", **context)
self._tokens: Dict[str, str] = {} # [/DEF:__init__:Function]
self._authenticated = False # [/DEF:AuthenticationError:Class]
self.logger.info("[APIClient.__init__][Exit] APIClient initialized.")
# [/DEF:__init__:Function] # [DEF:PermissionDeniedError:Class]
# @PURPOSE: Exception raised when access is denied.
# [DEF:_init_session:Function] class PermissionDeniedError(AuthenticationError):
# @PURPOSE: Создает и настраивает `requests.Session` с retry-логикой. # [DEF:__init__:Function]
# @PRE: self.request_settings must be initialized. # @PURPOSE: Initializes the permission denied error.
# @POST: Returns a configured requests.Session instance. # @PRE: message is a string, context is a dict.
# @RETURN: requests.Session - Настроенная сессия. # @POST: PermissionDeniedError is initialized.
def _init_session(self) -> requests.Session: def __init__(self, message: str = "Permission denied", **context: Any):
with belief_scope("_init_session"): with belief_scope("PermissionDeniedError.__init__"):
session = requests.Session() super().__init__(message, **context)
retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) # [/DEF:__init__:Function]
adapter = HTTPAdapter(max_retries=retries) # [/DEF:PermissionDeniedError:Class]
session.mount('http://', adapter)
session.mount('https://', adapter) # [DEF:DashboardNotFoundError:Class]
if not self.request_settings["verify_ssl"]: # @PURPOSE: Exception raised when a dashboard cannot be found.
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) class DashboardNotFoundError(SupersetAPIError):
self.logger.warning("[_init_session][State] SSL verification disabled.") # [DEF:__init__:Function]
session.verify = self.request_settings["verify_ssl"] # @PURPOSE: Initializes the not found error with resource ID.
return session # @PRE: resource_id is provided.
# [/DEF:_init_session:Function] # @POST: DashboardNotFoundError is initialized.
def __init__(self, resource_id: Union[int, str], message: str = "Dashboard not found", **context: Any):
# [DEF:authenticate:Function] with belief_scope("DashboardNotFoundError.__init__"):
# @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены. super().__init__(f"Dashboard '{resource_id}' {message}", subtype="not_found", resource_id=resource_id, **context)
# @PRE: self.auth and self.base_url must be valid. # [/DEF:__init__:Function]
# @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`. # [/DEF:DashboardNotFoundError:Class]
# @RETURN: Dict[str, str] - Словарь с токенами.
# @THROW: AuthenticationError, NetworkError - при ошибках. # [DEF:NetworkError:Class]
def authenticate(self) -> Dict[str, str]: # @PURPOSE: Exception raised when a network level error occurs.
with belief_scope("authenticate"): class NetworkError(Exception):
self.logger.info("[authenticate][Enter] Authenticating to %s", self.base_url) # [DEF:__init__:Function]
try: # @PURPOSE: Initializes the network error.
login_url = f"{self.base_url}/security/login" # @PRE: message is a string.
response = self.session.post(login_url, json=self.auth, timeout=self.request_settings["timeout"]) # @POST: NetworkError is initialized.
response.raise_for_status() def __init__(self, message: str = "Network connection failed", **context: Any):
access_token = response.json()["access_token"] with belief_scope("NetworkError.__init__"):
self.context = context
csrf_url = f"{self.base_url}/security/csrf_token/" super().__init__(f"[NETWORK_FAILURE] {message} | Context: {self.context}")
csrf_response = self.session.get(csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.request_settings["timeout"]) # [/DEF:__init__:Function]
csrf_response.raise_for_status() # [/DEF:NetworkError:Class]
self._tokens = {"access_token": access_token, "csrf_token": csrf_response.json()["result"]} # [DEF:APIClient:Class]
self._authenticated = True # @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
self.logger.info("[authenticate][Exit] Authenticated successfully.") class APIClient:
return self._tokens DEFAULT_TIMEOUT = 30
except requests.exceptions.HTTPError as e:
raise AuthenticationError(f"Authentication failed: {e}") from e # [DEF:__init__:Function]
except (requests.exceptions.RequestException, KeyError) as e: # @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером.
raise NetworkError(f"Network or parsing error during authentication: {e}") from e # @PARAM: config (Dict[str, Any]) - Конфигурация.
# [/DEF:authenticate:Function] # @PARAM: verify_ssl (bool) - Проверять ли SSL.
# @PARAM: timeout (int) - Таймаут запросов.
@property # @PRE: config must contain 'base_url' and 'auth'.
# [DEF:headers:Function] # @POST: APIClient instance is initialized with a session.
# @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов. def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT):
# @PRE: APIClient is initialized and authenticated or can be authenticated. with belief_scope("__init__"):
# @POST: Returns headers including auth tokens. app_logger.info("[APIClient.__init__][Entry] Initializing APIClient.")
def headers(self) -> Dict[str, str]: self.base_url: str = config.get("base_url", "")
with belief_scope("headers"): self.auth = config.get("auth")
if not self._authenticated: self.authenticate() self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
return { self.session = self._init_session()
"Authorization": f"Bearer {self._tokens['access_token']}", self._tokens: Dict[str, str] = {}
"X-CSRFToken": self._tokens.get("csrf_token", ""), self._authenticated = False
"Referer": self.base_url, app_logger.info("[APIClient.__init__][Exit] APIClient initialized.")
"Content-Type": "application/json" # [/DEF:__init__:Function]
}
# [/DEF:headers:Function] # [DEF:_init_session:Function]
# @PURPOSE: Создает и настраивает `requests.Session` с retry-логикой.
# [DEF:request:Function] # @PRE: self.request_settings must be initialized.
# @PURPOSE: Выполняет универсальный HTTP-запрос к API. # @POST: Returns a configured requests.Session instance.
# @PARAM: method (str) - HTTP метод. # @RETURN: requests.Session - Настроенная сессия.
# @PARAM: endpoint (str) - API эндпоинт. def _init_session(self) -> requests.Session:
# @PARAM: headers (Optional[Dict]) - Дополнительные заголовки. with belief_scope("_init_session"):
# @PARAM: raw_response (bool) - Возвращать ли сырой ответ. session = requests.Session()
# @PRE: method and endpoint must be strings. retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
# @POST: Returns response content or raw Response object. adapter = HTTPAdapter(max_retries=retries)
# @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`. session.mount('http://', adapter)
# @THROW: SupersetAPIError, NetworkError и их подклассы. session.mount('https://', adapter)
def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]: if not self.request_settings["verify_ssl"]:
with belief_scope("request"): urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
full_url = f"{self.base_url}{endpoint}" app_logger.warning("[_init_session][State] SSL verification disabled.")
_headers = self.headers.copy() session.verify = self.request_settings["verify_ssl"]
if headers: _headers.update(headers) return session
# [/DEF:_init_session:Function]
try:
response = self.session.request(method, full_url, headers=_headers, **kwargs) # [DEF:authenticate:Function]
response.raise_for_status() # @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены.
return response if raw_response else response.json() # @PRE: self.auth and self.base_url must be valid.
except requests.exceptions.HTTPError as e: # @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`.
self._handle_http_error(e, endpoint) # @RETURN: Dict[str, str] - Словарь с токенами.
except requests.exceptions.RequestException as e: # @THROW: AuthenticationError, NetworkError - при ошибках.
self._handle_network_error(e, full_url) def authenticate(self) -> Dict[str, str]:
# [/DEF:request:Function] with belief_scope("authenticate"):
app_logger.info("[authenticate][Enter] Authenticating to %s", self.base_url)
# [DEF:_handle_http_error:Function] try:
# @PURPOSE: (Helper) Преобразует HTTP ошибки в кастомные исключения. login_url = f"{self.base_url}/security/login"
# @PARAM: e (requests.exceptions.HTTPError) - Ошибка. response = self.session.post(login_url, json=self.auth, timeout=self.request_settings["timeout"])
# @PARAM: endpoint (str) - Эндпоинт. response.raise_for_status()
# @PRE: e must be a valid HTTPError with a response. access_token = response.json()["access_token"]
# @POST: Raises a specific SupersetAPIError or subclass.
def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str): csrf_url = f"{self.base_url}/security/csrf_token/"
with belief_scope("_handle_http_error"): csrf_response = self.session.get(csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.request_settings["timeout"])
status_code = e.response.status_code csrf_response.raise_for_status()
if status_code == 404: raise DashboardNotFoundError(endpoint) from e
if status_code == 403: raise PermissionDeniedError() from e self._tokens = {"access_token": access_token, "csrf_token": csrf_response.json()["result"]}
if status_code == 401: raise AuthenticationError() from e self._authenticated = True
raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e app_logger.info("[authenticate][Exit] Authenticated successfully.")
# [/DEF:_handle_http_error:Function] return self._tokens
except requests.exceptions.HTTPError as e:
# [DEF:_handle_network_error:Function] raise AuthenticationError(f"Authentication failed: {e}") from e
# @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`. except (requests.exceptions.RequestException, KeyError) as e:
# @PARAM: e (requests.exceptions.RequestException) - Ошибка. raise NetworkError(f"Network or parsing error during authentication: {e}") from e
# @PARAM: url (str) - URL. # [/DEF:authenticate:Function]
# @PRE: e must be a RequestException.
# @POST: Raises a NetworkError. @property
def _handle_network_error(self, e: requests.exceptions.RequestException, url: str): # [DEF:headers:Function]
with belief_scope("_handle_network_error"): # @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов.
if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout" # @PRE: APIClient is initialized and authenticated or can be authenticated.
elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error" # @POST: Returns headers including auth tokens.
else: msg = f"Unknown network error: {e}" def headers(self) -> Dict[str, str]:
raise NetworkError(msg, url=url) from e with belief_scope("headers"):
# [/DEF:_handle_network_error:Function] if not self._authenticated: self.authenticate()
return {
# [DEF:upload_file:Function] "Authorization": f"Bearer {self._tokens['access_token']}",
# @PURPOSE: Загружает файл на сервер через multipart/form-data. "X-CSRFToken": self._tokens.get("csrf_token", ""),
# @PARAM: endpoint (str) - Эндпоинт. "Referer": self.base_url,
# @PARAM: file_info (Dict[str, Any]) - Информация о файле. "Content-Type": "application/json"
# @PARAM: extra_data (Optional[Dict]) - Дополнительные данные. }
# @PARAM: timeout (Optional[int]) - Таймаут. # [/DEF:headers:Function]
# @PRE: file_info must contain 'file_obj' and 'file_name'.
# @POST: File is uploaded and response returned. # [DEF:request:Function]
# @RETURN: Ответ API в виде словаря. # @PURPOSE: Выполняет универсальный HTTP-запрос к API.
# @THROW: SupersetAPIError, NetworkError, TypeError. # @PARAM: method (str) - HTTP метод.
def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict: # @PARAM: endpoint (str) - API эндпоинт.
with belief_scope("upload_file"): # @PARAM: headers (Optional[Dict]) - Дополнительные заголовки.
full_url = f"{self.base_url}{endpoint}" # @PARAM: raw_response (bool) - Возвращать ли сырой ответ.
_headers = self.headers.copy(); _headers.pop('Content-Type', None) # @PRE: method and endpoint must be strings.
# @POST: Returns response content or raw Response object.
file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file") # @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`.
# @THROW: SupersetAPIError, NetworkError и их подклассы.
files_payload = {} def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]:
if isinstance(file_obj, (str, Path)): with belief_scope("request"):
with open(file_obj, 'rb') as f: full_url = f"{self.base_url}{endpoint}"
files_payload = {form_field: (file_name, f.read(), 'application/x-zip-compressed')} _headers = self.headers.copy()
elif isinstance(file_obj, io.BytesIO): if headers: _headers.update(headers)
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
else: try:
raise TypeError(f"Unsupported file_obj type: {type(file_obj)}") response = self.session.request(method, full_url, headers=_headers, **kwargs)
response.raise_for_status()
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout) return response if raw_response else response.json()
# [/DEF:upload_file:Function] except requests.exceptions.HTTPError as e:
self._handle_http_error(e, endpoint)
# [DEF:_perform_upload:Function] except requests.exceptions.RequestException as e:
# @PURPOSE: (Helper) Выполняет POST запрос с файлом. self._handle_network_error(e, full_url)
# @PARAM: url (str) - URL. # [/DEF:request:Function]
# @PARAM: files (Dict) - Файлы.
# @PARAM: data (Optional[Dict]) - Данные. # [DEF:_handle_http_error:Function]
# @PARAM: headers (Dict) - Заголовки. # @PURPOSE: (Helper) Преобразует HTTP ошибки в кастомные исключения.
# @PARAM: timeout (Optional[int]) - Таймаут. # @PARAM: e (requests.exceptions.HTTPError) - Ошибка.
# @PRE: url, files, and headers must be provided. # @PARAM: endpoint (str) - Эндпоинт.
# @POST: POST request is performed and JSON response returned. # @PRE: e must be a valid HTTPError with a response.
# @RETURN: Dict - Ответ. # @POST: Raises a specific SupersetAPIError or subclass.
def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict: def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str):
with belief_scope("_perform_upload"): with belief_scope("_handle_http_error"):
try: status_code = e.response.status_code
response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"]) if status_code == 404: raise DashboardNotFoundError(endpoint) from e
response.raise_for_status() if status_code == 403: raise PermissionDeniedError() from e
# Добавляем логирование для отладки if status_code == 401: raise AuthenticationError() from e
if response.status_code == 200: raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e
try: # [/DEF:_handle_http_error:Function]
return response.json()
except Exception as json_e: # [DEF:_handle_network_error:Function]
self.logger.debug(f"[_perform_upload][Debug] Response is not valid JSON: {response.text[:200]}...") # @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`.
raise SupersetAPIError(f"API error during upload: Response is not valid JSON: {json_e}") from json_e # @PARAM: e (requests.exceptions.RequestException) - Ошибка.
return response.json() # @PARAM: url (str) - URL.
except requests.exceptions.HTTPError as e: # @PRE: e must be a RequestException.
raise SupersetAPIError(f"API error during upload: {e.response.text}") from e # @POST: Raises a NetworkError.
except requests.exceptions.RequestException as e: def _handle_network_error(self, e: requests.exceptions.RequestException, url: str):
raise NetworkError(f"Network error during upload: {e}", url=url) from e with belief_scope("_handle_network_error"):
# [/DEF:_perform_upload:Function] if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout"
elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error"
# [DEF:fetch_paginated_count:Function] else: msg = f"Unknown network error: {e}"
# @PURPOSE: Получает общее количество элементов для пагинации. raise NetworkError(msg, url=url) from e
# @PARAM: endpoint (str) - Эндпоинт. # [/DEF:_handle_network_error:Function]
# @PARAM: query_params (Dict) - Параметры запроса.
# @PARAM: count_field (str) - Поле с количеством. # [DEF:upload_file:Function]
# @PRE: query_params must be a dictionary. # @PURPOSE: Загружает файл на сервер через multipart/form-data.
# @POST: Returns total count of items. # @PARAM: endpoint (str) - Эндпоинт.
# @RETURN: int - Количество. # @PARAM: file_info (Dict[str, Any]) - Информация о файле.
def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int: # @PARAM: extra_data (Optional[Dict]) - Дополнительные данные.
with belief_scope("fetch_paginated_count"): # @PARAM: timeout (Optional[int]) - Таймаут.
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query_params)})) # @PRE: file_info must contain 'file_obj' and 'file_name'.
return response_json.get(count_field, 0) # @POST: File is uploaded and response returned.
# [/DEF:fetch_paginated_count:Function] # @RETURN: Ответ API в виде словаря.
# @THROW: SupersetAPIError, NetworkError, TypeError.
# [DEF:fetch_paginated_data:Function] def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта. with belief_scope("upload_file"):
# @PARAM: endpoint (str) - Эндпоинт. full_url = f"{self.base_url}{endpoint}"
# @PARAM: pagination_options (Dict[str, Any]) - Опции пагинации. _headers = self.headers.copy(); _headers.pop('Content-Type', None)
# @PRE: pagination_options must contain 'base_query', 'total_count', 'results_field'.
# @POST: Returns all items across all pages. file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file")
# @RETURN: List[Any] - Список данных.
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]: files_payload = {}
with belief_scope("fetch_paginated_data"): if isinstance(file_obj, (str, Path)):
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"] with open(file_obj, 'rb') as f:
results_field, page_size = pagination_options["results_field"], base_query.get('page_size') files_payload = {form_field: (file_name, f.read(), 'application/x-zip-compressed')}
assert page_size and page_size > 0, "'page_size' must be a positive number." elif isinstance(file_obj, io.BytesIO):
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
results = [] else:
for page in range((total_count + page_size - 1) // page_size): raise TypeError(f"Unsupported file_obj type: {type(file_obj)}")
query = {**base_query, 'page': page}
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)})) return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
results.extend(response_json.get(results_field, [])) # [/DEF:upload_file:Function]
return results
# [/DEF:fetch_paginated_data:Function] # [DEF:_perform_upload:Function]
# @PURPOSE: (Helper) Выполняет POST запрос с файлом.
# [/DEF:APIClient:Class] # @PARAM: url (str) - URL.
# @PARAM: files (Dict) - Файлы.
# [/DEF:superset_tool.utils.network:Module] # @PARAM: data (Optional[Dict]) - Данные.
# @PARAM: headers (Dict) - Заголовки.
# @PARAM: timeout (Optional[int]) - Таймаут.
# @PRE: url, files, and headers must be provided.
# @POST: POST request is performed and JSON response returned.
# @RETURN: Dict - Ответ.
def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict:
with belief_scope("_perform_upload"):
try:
response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"])
response.raise_for_status()
if response.status_code == 200:
try:
return response.json()
except Exception as json_e:
app_logger.debug(f"[_perform_upload][Debug] Response is not valid JSON: {response.text[:200]}...")
raise SupersetAPIError(f"API error during upload: Response is not valid JSON: {json_e}") from json_e
return response.json()
except requests.exceptions.HTTPError as e:
raise SupersetAPIError(f"API error during upload: {e.response.text}") from e
except requests.exceptions.RequestException as e:
raise NetworkError(f"Network error during upload: {e}", url=url) from e
# [/DEF:_perform_upload:Function]
# [DEF:fetch_paginated_count:Function]
# @PURPOSE: Получает общее количество элементов для пагинации.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: query_params (Dict) - Параметры запроса.
# @PARAM: count_field (str) - Поле с количеством.
# @PRE: query_params must be a dictionary.
# @POST: Returns total count of items.
# @RETURN: int - Количество.
def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int:
with belief_scope("fetch_paginated_count"):
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query_params)}))
return response_json.get(count_field, 0)
# [/DEF:fetch_paginated_count:Function]
# [DEF:fetch_paginated_data:Function]
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: pagination_options (Dict[str, Any]) - Опции пагинации.
# @PRE: pagination_options must contain 'base_query', 'total_count', 'results_field'.
# @POST: Returns all items across all pages.
# @RETURN: List[Any] - Список данных.
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
with belief_scope("fetch_paginated_data"):
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"]
results_field, page_size = pagination_options["results_field"], base_query.get('page_size')
assert page_size and page_size > 0, "'page_size' must be a positive number."
results = []
for page in range((total_count + page_size - 1) // page_size):
query = {**base_query, 'page': page}
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
results.extend(response_json.get(results_field, []))
return results
# [/DEF:fetch_paginated_data:Function]
# [/DEF:APIClient:Class]
# [/DEF:backend.core.utils.network:Module]

73
backend/src/models/git.py Normal file
View File

@@ -0,0 +1,73 @@
# [DEF:GitModels:Module]
# @SEMANTICS: git, models, sqlalchemy, database, schema
# @PURPOSE: Git-specific SQLAlchemy models for configuration and repository tracking.
# @LAYER: Model
# @RELATION: specs/011-git-integration-dashboard/data-model.md
import enum
from datetime import datetime
from sqlalchemy import Column, String, Integer, DateTime, Enum, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import UUID
import uuid
from src.core.database import Base
class GitProvider(str, enum.Enum):
GITHUB = "GITHUB"
GITLAB = "GITLAB"
GITEA = "GITEA"
class GitStatus(str, enum.Enum):
CONNECTED = "CONNECTED"
FAILED = "FAILED"
UNKNOWN = "UNKNOWN"
class SyncStatus(str, enum.Enum):
CLEAN = "CLEAN"
DIRTY = "DIRTY"
CONFLICT = "CONFLICT"
class GitServerConfig(Base):
"""
[DEF:GitServerConfig:Class]
Configuration for a Git server connection.
"""
__tablename__ = "git_server_configs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(255), nullable=False)
provider = Column(Enum(GitProvider), nullable=False)
url = Column(String(255), nullable=False)
pat = Column(String(255), nullable=False) # PERSONAL ACCESS TOKEN
default_repository = Column(String(255), nullable=True)
status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN)
last_validated = Column(DateTime, default=datetime.utcnow)
class GitRepository(Base):
"""
[DEF:GitRepository:Class]
Tracking for a local Git repository linked to a dashboard.
"""
__tablename__ = "git_repositories"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
dashboard_id = Column(Integer, nullable=False, unique=True)
config_id = Column(String(36), ForeignKey("git_server_configs.id"), nullable=False)
remote_url = Column(String(255), nullable=False)
local_path = Column(String(255), nullable=False)
current_branch = Column(String(255), default="main")
sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN)
class DeploymentEnvironment(Base):
"""
[DEF:DeploymentEnvironment:Class]
Target Superset environments for dashboard deployment.
"""
__tablename__ = "deployment_environments"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(255), nullable=False)
superset_url = Column(String(255), nullable=False)
superset_token = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
# [/DEF:GitModels:Module]

View File

@@ -0,0 +1,31 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
# [DEF:FileCategory:Class]
# @PURPOSE: Enumeration of supported file categories in the storage system.
class FileCategory(str, Enum):
BACKUP = "backups"
REPOSITORY = "repositorys"
# [/DEF:FileCategory:Class]
# [DEF:StorageConfig:Class]
# @PURPOSE: Configuration model for the storage system, defining paths and naming patterns.
class StorageConfig(BaseModel):
root_path: str = Field(default="backups", description="Absolute path to the storage root directory.")
backup_structure_pattern: str = Field(default="{category}/", description="Pattern for backup directory structure.")
repo_structure_pattern: str = Field(default="{category}/", description="Pattern for repository directory structure.")
filename_pattern: str = Field(default="{name}_{timestamp}", description="Pattern for filenames.")
# [/DEF:StorageConfig:Class]
# [DEF:StoredFile:Class]
# @PURPOSE: Data model representing metadata for a file stored in the system.
class StoredFile(BaseModel):
name: str = Field(..., description="Name of the file (including extension).")
path: str = Field(..., description="Relative path from storage root.")
size: int = Field(..., ge=0, description="Size of the file in bytes.")
created_at: datetime = Field(..., description="Creation timestamp.")
category: FileCategory = Field(..., description="Category of the file.")
mime_type: Optional[str] = Field(None, description="MIME type of the file.")
# [/DEF:StoredFile:Class]

View File

@@ -12,10 +12,9 @@ from requests.exceptions import RequestException
from ..core.plugin_base import PluginBase from ..core.plugin_base import PluginBase
from ..core.logger import belief_scope from ..core.logger import belief_scope
from superset_tool.client import SupersetClient from ..core.superset_client import SupersetClient
from superset_tool.exceptions import SupersetAPIError from ..core.utils.network import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger from ..core.utils.fileio import (
from superset_tool.utils.fileio import (
save_and_unpack_dashboard, save_and_unpack_dashboard,
archive_exports, archive_exports,
sanitize_filename, sanitize_filename,
@@ -23,7 +22,6 @@ from superset_tool.utils.fileio import (
remove_empty_directories, remove_empty_directories,
RetentionPolicy RetentionPolicy
) )
from superset_tool.utils.init_clients import setup_clients
from ..dependencies import get_config_manager from ..dependencies import get_config_manager
# [DEF:BackupPlugin:Class] # [DEF:BackupPlugin:Class]
@@ -77,6 +75,15 @@ class BackupPlugin(PluginBase):
return "1.0.0" return "1.0.0"
# [/DEF:version:Function] # [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the backup plugin.
# @RETURN: str - "/tools/backups"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/tools/backups"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function] # [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for backup plugin parameters. # @PURPOSE: Returns the JSON schema for backup plugin parameters.
# @PRE: Plugin instance exists. # @PRE: Plugin instance exists.
@@ -86,7 +93,7 @@ class BackupPlugin(PluginBase):
with belief_scope("get_schema"): with belief_scope("get_schema"):
config_manager = get_config_manager() config_manager = get_config_manager()
envs = [e.name for e in config_manager.get_environments()] envs = [e.name for e in config_manager.get_environments()]
default_path = config_manager.get_config().settings.backup_path default_path = config_manager.get_config().settings.storage.root_path
return { return {
"type": "object", "type": "object",
@@ -97,14 +104,8 @@ class BackupPlugin(PluginBase):
"description": "The Superset environment to back up.", "description": "The Superset environment to back up.",
"enum": envs if envs else [], "enum": envs if envs else [],
}, },
"backup_path": {
"type": "string",
"title": "Backup Path",
"description": "The root directory to save backups to.",
"default": default_path
}
}, },
"required": ["env", "backup_path"], "required": ["env"],
} }
# [/DEF:get_schema:Function] # [/DEF:get_schema:Function]
@@ -128,28 +129,29 @@ class BackupPlugin(PluginBase):
if not env: if not env:
raise KeyError("env") raise KeyError("env")
backup_path_str = params.get("backup_path") or config_manager.get_config().settings.backup_path storage_settings = config_manager.get_config().settings.storage
backup_path = Path(backup_path_str) # Use 'backups' subfolder within the storage root
backup_path = Path(storage_settings.root_path) / "backups"
logger = SupersetLogger(log_dir=backup_path / "Logs", console=True) from ..core.logger import logger as app_logger
logger.info(f"[BackupPlugin][Entry] Starting backup for {env}.") app_logger.info(f"[BackupPlugin][Entry] Starting backup for {env}.")
try: try:
config_manager = get_config_manager() config_manager = get_config_manager()
if not config_manager.has_environments(): if not config_manager.has_environments():
raise ValueError("No Superset environments configured. Please add an environment in Settings.") raise ValueError("No Superset environments configured. Please add an environment in Settings.")
clients = setup_clients(logger, custom_envs=config_manager.get_environments()) env_config = config_manager.get_environment(env)
client = clients.get(env) if not env_config:
if not client:
raise ValueError(f"Environment '{env}' not found in configuration.") raise ValueError(f"Environment '{env}' not found in configuration.")
client = SupersetClient(env_config)
dashboard_count, dashboard_meta = client.get_dashboards() dashboard_count, dashboard_meta = client.get_dashboards()
logger.info(f"[BackupPlugin][Progress] Found {dashboard_count} dashboards to export in {env}.") app_logger.info(f"[BackupPlugin][Progress] Found {dashboard_count} dashboards to export in {env}.")
if dashboard_count == 0: if dashboard_count == 0:
logger.info("[BackupPlugin][Exit] No dashboards to back up.") app_logger.info("[BackupPlugin][Exit] No dashboards to back up.")
return return
for db in dashboard_meta: for db in dashboard_meta:
@@ -169,23 +171,22 @@ class BackupPlugin(PluginBase):
zip_content=zip_content, zip_content=zip_content,
original_filename=filename, original_filename=filename,
output_dir=dashboard_dir, output_dir=dashboard_dir,
unpack=False, unpack=False
logger=logger
) )
archive_exports(str(dashboard_dir), policy=RetentionPolicy(), logger=logger) archive_exports(str(dashboard_dir), policy=RetentionPolicy())
except (SupersetAPIError, RequestException, IOError, OSError) as db_error: except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
logger.error(f"[BackupPlugin][Failure] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True) app_logger.error(f"[BackupPlugin][Failure] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True)
continue continue
consolidate_archive_folders(backup_path / env.upper(), logger=logger) consolidate_archive_folders(backup_path / env.upper())
remove_empty_directories(str(backup_path / env.upper()), logger=logger) remove_empty_directories(str(backup_path / env.upper()))
logger.info(f"[BackupPlugin][CoherenceCheck:Passed] Backup logic completed for {env}.") app_logger.info(f"[BackupPlugin][CoherenceCheck:Passed] Backup logic completed for {env}.")
except (RequestException, IOError, KeyError) as e: except (RequestException, IOError, KeyError) as e:
logger.critical(f"[BackupPlugin][Failure] Fatal error during backup for {env}: {e}", exc_info=True) app_logger.critical(f"[BackupPlugin][Failure] Fatal error during backup for {env}: {e}", exc_info=True)
raise e raise e
# [/DEF:execute:Function] # [/DEF:execute:Function]
# [/DEF:BackupPlugin:Class] # [/DEF:BackupPlugin:Class]

View File

@@ -63,6 +63,15 @@ class DebugPlugin(PluginBase):
return "1.0.0" return "1.0.0"
# [/DEF:version:Function] # [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the debug plugin.
# @RETURN: str - "/tools/debug"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/tools/debug"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function] # [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the debug plugin parameters. # @PURPOSE: Returns the JSON schema for the debug plugin parameters.
# @PRE: Plugin instance exists. # @PRE: Plugin instance exists.

View File

@@ -0,0 +1,385 @@
# [DEF:backend.src.plugins.git_plugin:Module]
#
# @SEMANTICS: git, plugin, dashboard, version_control, sync, deploy
# @PURPOSE: Предоставляет плагин для версионирования и развертывания дашбордов Superset.
# @LAYER: Plugin
# @RELATION: INHERITS_FROM -> src.core.plugin_base.PluginBase
# @RELATION: USES -> src.services.git_service.GitService
# @RELATION: USES -> src.core.superset_client.SupersetClient
# @RELATION: USES -> src.core.config_manager.ConfigManager
#
# @INVARIANT: Все операции с Git должны выполняться через GitService.
# @CONSTRAINT: Плагин работает только с распакованными YAML-экспортами Superset.
# [SECTION: IMPORTS]
import os
import io
import shutil
import zipfile
from pathlib import Path
from typing import Dict, Any, Optional
from src.core.plugin_base import PluginBase
from src.services.git_service import GitService
from src.core.logger import logger, belief_scope
from src.core.config_manager import ConfigManager
from src.core.superset_client import SupersetClient
# [/SECTION]
# [DEF:GitPlugin:Class]
# @PURPOSE: Реализация плагина Git Integration для управления версиями дашбордов.
class GitPlugin(PluginBase):
# [DEF:__init__:Function]
# @PURPOSE: Инициализирует плагин и его зависимости.
# @PRE: config.json exists or shared config_manager is available.
# @POST: Инициализированы git_service и config_manager.
def __init__(self):
with belief_scope("GitPlugin.__init__"):
logger.info("[GitPlugin.__init__][Entry] Initializing GitPlugin.")
self.git_service = GitService()
# Robust config path resolution:
# 1. Try absolute path from src/dependencies.py style if possible
# 2. Try relative paths based on common execution patterns
if os.path.exists("../config.json"):
config_path = "../config.json"
elif os.path.exists("config.json"):
config_path = "config.json"
else:
# Fallback to the one initialized in dependencies if we can import it
try:
from src.dependencies import config_manager
self.config_manager = config_manager
logger.info("[GitPlugin.__init__][Exit] GitPlugin initialized using shared config_manager.")
return
except:
config_path = "config.json"
self.config_manager = ConfigManager(config_path)
logger.info(f"[GitPlugin.__init__][Exit] GitPlugin initialized with {config_path}")
# [/DEF:__init__:Function]
@property
# [DEF:id:Function]
# @PURPOSE: Returns the plugin identifier.
# @PRE: GitPlugin is initialized.
# @POST: Returns 'git-integration'.
def id(self) -> str:
with belief_scope("GitPlugin.id"):
return "git-integration"
# [/DEF:id:Function]
@property
# [DEF:name:Function]
# @PURPOSE: Returns the plugin name.
# @PRE: GitPlugin is initialized.
# @POST: Returns the human-readable name.
def name(self) -> str:
with belief_scope("GitPlugin.name"):
return "Git Integration"
# [/DEF:name:Function]
@property
# [DEF:description:Function]
# @PURPOSE: Returns the plugin description.
# @PRE: GitPlugin is initialized.
# @POST: Returns the plugin's purpose description.
def description(self) -> str:
with belief_scope("GitPlugin.description"):
return "Version control for Superset dashboards"
# [/DEF:description:Function]
@property
# [DEF:version:Function]
# @PURPOSE: Returns the plugin version.
# @PRE: GitPlugin is initialized.
# @POST: Returns the version string.
def version(self) -> str:
with belief_scope("GitPlugin.version"):
return "0.1.0"
# [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the git plugin.
# @RETURN: str - "/git"
def ui_route(self) -> str:
with belief_scope("GitPlugin.ui_route"):
return "/git"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Возвращает JSON-схему параметров для выполнения задач плагина.
# @PRE: GitPlugin is initialized.
# @POST: Returns a JSON schema dictionary.
# @RETURN: Dict[str, Any] - Схема параметров.
def get_schema(self) -> Dict[str, Any]:
with belief_scope("GitPlugin.get_schema"):
return {
"type": "object",
"properties": {
"operation": {"type": "string", "enum": ["sync", "deploy", "history"]},
"dashboard_id": {"type": "integer"},
"environment_id": {"type": "string"},
"source_env_id": {"type": "string"}
},
"required": ["operation", "dashboard_id"]
}
# [/DEF:get_schema:Function]
# [DEF:initialize:Function]
# @PURPOSE: Выполняет начальную настройку плагина.
# @PRE: GitPlugin is initialized.
# @POST: Плагин готов к выполнению задач.
async def initialize(self):
with belief_scope("GitPlugin.initialize"):
logger.info("[GitPlugin.initialize][Action] Initializing Git Integration Plugin logic.")
# [DEF:execute:Function]
# @PURPOSE: Основной метод выполнения задач плагина.
# @PRE: task_data содержит 'operation' и 'dashboard_id'.
# @POST: Возвращает результат выполнения операции.
# @PARAM: task_data (Dict[str, Any]) - Данные задачи.
# @RETURN: Dict[str, Any] - Статус и сообщение.
# @RELATION: CALLS -> self._handle_sync
# @RELATION: CALLS -> self._handle_deploy
async def execute(self, task_data: Dict[str, Any]) -> Dict[str, Any]:
with belief_scope("GitPlugin.execute"):
operation = task_data.get("operation")
dashboard_id = task_data.get("dashboard_id")
logger.info(f"[GitPlugin.execute][Entry] Executing operation: {operation} for dashboard {dashboard_id}")
if operation == "sync":
source_env_id = task_data.get("source_env_id")
result = await self._handle_sync(dashboard_id, source_env_id)
elif operation == "deploy":
env_id = task_data.get("environment_id")
result = await self._handle_deploy(dashboard_id, env_id)
elif operation == "history":
result = {"status": "success", "message": "History available via API"}
else:
logger.error(f"[GitPlugin.execute][Coherence:Failed] Unknown operation: {operation}")
raise ValueError(f"Unknown operation: {operation}")
logger.info(f"[GitPlugin.execute][Exit] Operation {operation} completed.")
return result
# [/DEF:execute:Function]
# [DEF:_handle_sync:Function]
# @PURPOSE: Экспортирует дашборд из Superset и распаковывает в Git-репозиторий.
# @PRE: Репозиторий для дашборда должен существовать.
# @POST: Файлы в репозитории обновлены до текущего состояния в Superset.
# @PARAM: dashboard_id (int) - ID дашборда.
# @PARAM: source_env_id (Optional[str]) - ID исходного окружения.
# @RETURN: Dict[str, str] - Результат синхронизации.
# @SIDE_EFFECT: Изменяет файлы в локальной рабочей директории репозитория.
# @RELATION: CALLS -> src.services.git_service.GitService.get_repo
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.export_dashboard
async def _handle_sync(self, dashboard_id: int, source_env_id: Optional[str] = None) -> Dict[str, str]:
with belief_scope("GitPlugin._handle_sync"):
try:
# 1. Получение репозитория
repo = self.git_service.get_repo(dashboard_id)
repo_path = Path(repo.working_dir)
logger.info(f"[_handle_sync][Action] Target repo path: {repo_path}")
# 2. Настройка клиента Superset
env = self._get_env(source_env_id)
client = SupersetClient(env)
client.authenticate()
# 3. Экспорт дашборда
logger.info(f"[_handle_sync][Action] Exporting dashboard {dashboard_id} from {env.name}")
zip_bytes, _ = client.export_dashboard(dashboard_id)
# 4. Распаковка с выравниванием структуры (flattening)
logger.info(f"[_handle_sync][Action] Unpacking export to {repo_path}")
# Список папок/файлов, которые мы ожидаем от Superset
managed_dirs = ["dashboards", "charts", "datasets", "databases"]
managed_files = ["metadata.yaml"]
# Очистка старых данных перед распаковкой, чтобы не оставалось "призраков"
for d in managed_dirs:
d_path = repo_path / d
if d_path.exists() and d_path.is_dir():
shutil.rmtree(d_path)
for f in managed_files:
f_path = repo_path / f
if f_path.exists():
f_path.unlink()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
# Superset экспортирует всё в подпапку dashboard_export_timestamp/
# Нам нужно найти это имя папки
namelist = zf.namelist()
if not namelist:
raise ValueError("Export ZIP is empty")
root_folder = namelist[0].split('/')[0]
logger.info(f"[_handle_sync][Action] Detected root folder in ZIP: {root_folder}")
for member in zf.infolist():
if member.filename.startswith(root_folder + "/") and len(member.filename) > len(root_folder) + 1:
# Убираем префикс папки
relative_path = member.filename[len(root_folder)+1:]
target_path = repo_path / relative_path
if member.is_dir():
target_path.mkdir(parents=True, exist_ok=True)
else:
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as source, open(target_path, "wb") as target:
shutil.copyfileobj(source, target)
# 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff)
try:
repo.git.add(A=True)
logger.info(f"[_handle_sync][Action] Changes staged in git")
except Exception as ge:
logger.warning(f"[_handle_sync][Action] Failed to stage changes: {ge}")
logger.info(f"[_handle_sync][Coherence:OK] Dashboard {dashboard_id} synced successfully.")
return {"status": "success", "message": "Dashboard synced and flattened in local repository"}
except Exception as e:
logger.error(f"[_handle_sync][Coherence:Failed] Sync failed: {e}")
raise
# [/DEF:_handle_sync:Function]
# [DEF:_handle_deploy:Function]
# @PURPOSE: Упаковывает репозиторий в ZIP и импортирует в целевое окружение Superset.
# @PRE: environment_id должен соответствовать настроенному окружению.
# @POST: Дашборд импортирован в целевой Superset.
# @PARAM: dashboard_id (int) - ID дашборда.
# @PARAM: env_id (str) - ID целевого окружения.
# @RETURN: Dict[str, Any] - Результат деплоя.
# @SIDE_EFFECT: Создает и удаляет временный ZIP-файл.
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.import_dashboard
async def _handle_deploy(self, dashboard_id: int, env_id: str) -> Dict[str, Any]:
with belief_scope("GitPlugin._handle_deploy"):
try:
if not env_id:
raise ValueError("Target environment ID required for deployment")
# 1. Получение репозитория
repo = self.git_service.get_repo(dashboard_id)
repo_path = Path(repo.working_dir)
# 2. Упаковка в ZIP
logger.info(f"[_handle_deploy][Action] Packing repository {repo_path} for deployment.")
zip_buffer = io.BytesIO()
# Superset expects a root directory in the ZIP (e.g., dashboard_export_20240101T000000/)
root_dir_name = f"dashboard_export_{dashboard_id}"
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(repo_path):
if ".git" in dirs:
dirs.remove(".git")
for file in files:
if file == ".git" or file.endswith(".zip"): continue
file_path = Path(root) / file
# Prepend the root directory name to the archive path
arcname = Path(root_dir_name) / file_path.relative_to(repo_path)
zf.write(file_path, arcname)
zip_buffer.seek(0)
# 3. Настройка клиента Superset
env = self.config_manager.get_environment(env_id)
if not env:
raise ValueError(f"Environment {env_id} not found")
client = SupersetClient(env)
client.authenticate()
# 4. Импорт
temp_zip_path = repo_path / f"deploy_{dashboard_id}.zip"
logger.info(f"[_handle_deploy][Action] Saving temporary zip to {temp_zip_path}")
with open(temp_zip_path, "wb") as f:
f.write(zip_buffer.getvalue())
try:
logger.info(f"[_handle_deploy][Action] Importing dashboard to {env.name}")
result = client.import_dashboard(temp_zip_path)
logger.info(f"[_handle_deploy][Coherence:OK] Deployment successful for dashboard {dashboard_id}.")
return {"status": "success", "message": f"Dashboard deployed to {env.name}", "details": result}
finally:
if temp_zip_path.exists():
os.remove(temp_zip_path)
except Exception as e:
logger.error(f"[_handle_deploy][Coherence:Failed] Deployment failed: {e}")
raise
# [/DEF:_handle_deploy:Function]
# [DEF:_get_env:Function]
# @PURPOSE: Вспомогательный метод для получения конфигурации окружения.
# @PARAM: env_id (Optional[str]) - ID окружения.
# @PRE: env_id is a string or None.
# @POST: Returns an Environment object from config or DB.
# @RETURN: Environment - Объект конфигурации окружения.
def _get_env(self, env_id: Optional[str] = None):
with belief_scope("GitPlugin._get_env"):
logger.info(f"[_get_env][Entry] Fetching environment for ID: {env_id}")
# Priority 1: ConfigManager (config.json)
if env_id:
env = self.config_manager.get_environment(env_id)
if env:
logger.info(f"[_get_env][Exit] Found environment by ID in ConfigManager: {env.name}")
return env
# Priority 2: Database (DeploymentEnvironment)
from src.core.database import SessionLocal
from src.models.git import DeploymentEnvironment
db = SessionLocal()
try:
if env_id:
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.id == env_id).first()
else:
# If no ID, try to find active or any environment in DB
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active == True).first()
if not db_env:
db_env = db.query(DeploymentEnvironment).first()
if db_env:
logger.info(f"[_get_env][Exit] Found environment in DB: {db_env.name}")
from src.core.config_models import Environment
# Use token as password for SupersetClient
return Environment(
id=db_env.id,
name=db_env.name,
url=db_env.superset_url,
username="admin",
password=db_env.superset_token,
verify_ssl=True
)
finally:
db.close()
# Priority 3: ConfigManager Default (if no env_id provided)
envs = self.config_manager.get_environments()
if envs:
if env_id:
# If env_id was provided but not found in DB or specifically by ID in config,
# but we have other envs, maybe it's one of them?
env = next((e for e in envs if e.id == env_id), None)
if env:
logger.info(f"[_get_env][Exit] Found environment {env_id} in ConfigManager list")
return env
if not env_id:
logger.info(f"[_get_env][Exit] Using first environment from ConfigManager: {envs[0].name}")
return envs[0]
logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}")
raise ValueError("No environments configured. Please add a Superset Environment in Settings.")
# [/DEF:_get_env:Function]
# [/DEF:initialize:Function]
# [/DEF:GitPlugin:Class]
# [/DEF:backend.src.plugins.git_plugin:Module]

View File

@@ -12,8 +12,7 @@ from ..core.superset_client import SupersetClient
from ..core.logger import logger, belief_scope from ..core.logger import logger, belief_scope
from ..core.database import SessionLocal from ..core.database import SessionLocal
from ..models.connection import ConnectionConfig from ..models.connection import ConnectionConfig
from superset_tool.utils.dataset_mapper import DatasetMapper from ..core.utils.dataset_mapper import DatasetMapper
from superset_tool.utils.logger import SupersetLogger
# [/SECTION] # [/SECTION]
# [DEF:MapperPlugin:Class] # [DEF:MapperPlugin:Class]
@@ -67,6 +66,15 @@ class MapperPlugin(PluginBase):
return "1.0.0" return "1.0.0"
# [/DEF:version:Function] # [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the mapper plugin.
# @RETURN: str - "/tools/mapper"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/tools/mapper"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function] # [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the mapper plugin parameters. # @PURPOSE: Returns the JSON schema for the mapper plugin parameters.
# @PRE: Plugin instance exists. # @PRE: Plugin instance exists.
@@ -173,9 +181,7 @@ class MapperPlugin(PluginBase):
logger.info(f"[MapperPlugin.execute][Action] Starting mapping for dataset {dataset_id} in {env_name}") logger.info(f"[MapperPlugin.execute][Action] Starting mapping for dataset {dataset_id} in {env_name}")
# Use internal SupersetLogger for DatasetMapper mapper = DatasetMapper()
s_logger = SupersetLogger(name="dataset_mapper_plugin")
mapper = DatasetMapper(s_logger)
try: try:
mapper.run_mapping( mapper.run_mapping(

View File

@@ -13,11 +13,9 @@ import re
from ..core.plugin_base import PluginBase from ..core.plugin_base import PluginBase
from ..core.logger import belief_scope from ..core.logger import belief_scope
from superset_tool.client import SupersetClient from ..core.superset_client import SupersetClient
from superset_tool.utils.init_clients import setup_clients from ..core.utils.fileio import create_temp_file, update_yamls, create_dashboard_export
from superset_tool.utils.fileio import create_temp_file, update_yamls, create_dashboard_export
from ..dependencies import get_config_manager from ..dependencies import get_config_manager
from superset_tool.utils.logger import SupersetLogger
from ..core.migration_engine import MigrationEngine from ..core.migration_engine import MigrationEngine
from ..core.database import SessionLocal from ..core.database import SessionLocal
from ..models.mapping import DatabaseMapping, Environment from ..models.mapping import DatabaseMapping, Environment
@@ -73,6 +71,15 @@ class MigrationPlugin(PluginBase):
return "1.0.0" return "1.0.0"
# [/DEF:version:Function] # [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the migration plugin.
# @RETURN: str - "/migration"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/migration"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function] # [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for migration plugin parameters. # @PURPOSE: Returns the JSON schema for migration plugin parameters.
# @PRE: Config manager is available. # @PRE: Config manager is available.
@@ -150,7 +157,7 @@ class MigrationPlugin(PluginBase):
from ..dependencies import get_task_manager from ..dependencies import get_task_manager
tm = get_task_manager() tm = get_task_manager()
class TaskLoggerProxy(SupersetLogger): class TaskLoggerProxy:
# [DEF:__init__:Function] # [DEF:__init__:Function]
# @PURPOSE: Initializes the proxy logger. # @PURPOSE: Initializes the proxy logger.
# @PRE: None. # @PRE: None.
@@ -158,7 +165,7 @@ class MigrationPlugin(PluginBase):
def __init__(self): def __init__(self):
with belief_scope("__init__"): with belief_scope("__init__"):
# Initialize parent with dummy values since we override methods # Initialize parent with dummy values since we override methods
super().__init__(console=False) pass
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:debug:Function] # [DEF:debug:Function]
@@ -246,9 +253,8 @@ class MigrationPlugin(PluginBase):
logger.info(f"[MigrationPlugin][State] Resolved environments: {from_env_name} -> {to_env_name}") logger.info(f"[MigrationPlugin][State] Resolved environments: {from_env_name} -> {to_env_name}")
all_clients = setup_clients(logger, custom_envs=environments) from_c = SupersetClient(src_env)
from_c = all_clients.get(from_env_name) to_c = SupersetClient(tgt_env)
to_c = all_clients.get(to_env_name)
if not from_c or not to_c: if not from_c or not to_c:
raise ValueError(f"Clients not initialized for environments: {from_env_name}, {to_env_name}") raise ValueError(f"Clients not initialized for environments: {from_env_name}, {to_env_name}")

View File

@@ -64,6 +64,15 @@ class SearchPlugin(PluginBase):
return "1.0.0" return "1.0.0"
# [/DEF:version:Function] # [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the search plugin.
# @RETURN: str - "/tools/search"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/tools/search"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function] # [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the search plugin parameters. # @PURPOSE: Returns the JSON schema for the search plugin parameters.
# @PRE: Plugin instance exists. # @PRE: Plugin instance exists.

View File

@@ -0,0 +1,3 @@
from .plugin import StoragePlugin
__all__ = ["StoragePlugin"]

View File

@@ -0,0 +1,333 @@
# [DEF:StoragePlugin:Module]
#
# @SEMANTICS: storage, files, filesystem, plugin
# @PURPOSE: Provides core filesystem operations for managing backups and repositories.
# @LAYER: App
# @RELATION: IMPLEMENTS -> PluginBase
# @RELATION: DEPENDS_ON -> backend.src.models.storage
#
# @INVARIANT: All file operations must be restricted to the configured storage root.
# [SECTION: IMPORTS]
import os
import shutil
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional
from fastapi import UploadFile
from ...core.plugin_base import PluginBase
from ...core.logger import belief_scope, logger
from ...models.storage import StoredFile, FileCategory, StorageConfig
from ...dependencies import get_config_manager
# [/SECTION]
# [DEF:StoragePlugin:Class]
# @PURPOSE: Implementation of the storage management plugin.
class StoragePlugin(PluginBase):
"""
Plugin for managing local file storage for backups and repositories.
"""
# [DEF:__init__:Function]
# @PURPOSE: Initializes the StoragePlugin and ensures required directories exist.
# @PRE: Configuration manager must be accessible.
# @POST: Storage root and category directories are created on disk.
def __init__(self):
with belief_scope("StoragePlugin:init"):
self.ensure_directories()
# [/DEF:__init__:Function]
@property
# [DEF:id:Function]
# @PURPOSE: Returns the unique identifier for the storage plugin.
# @PRE: None.
# @POST: Returns the plugin ID string.
# @RETURN: str - "storage-manager"
def id(self) -> str:
with belief_scope("StoragePlugin:id"):
return "storage-manager"
# [/DEF:id:Function]
@property
# [DEF:name:Function]
# @PURPOSE: Returns the human-readable name of the storage plugin.
# @PRE: None.
# @POST: Returns the plugin name string.
# @RETURN: str - "Storage Manager"
def name(self) -> str:
with belief_scope("StoragePlugin:name"):
return "Storage Manager"
# [/DEF:name:Function]
@property
# [DEF:description:Function]
# @PURPOSE: Returns a description of the storage plugin.
# @PRE: None.
# @POST: Returns the plugin description string.
# @RETURN: str - Plugin description.
def description(self) -> str:
with belief_scope("StoragePlugin:description"):
return "Manages local file storage for backups and repositories."
# [/DEF:description:Function]
@property
# [DEF:version:Function]
# @PURPOSE: Returns the version of the storage plugin.
# @PRE: None.
# @POST: Returns the version string.
# @RETURN: str - "1.0.0"
def version(self) -> str:
with belief_scope("StoragePlugin:version"):
return "1.0.0"
# [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the storage plugin.
# @RETURN: str - "/tools/storage"
def ui_route(self) -> str:
with belief_scope("StoragePlugin:ui_route"):
return "/tools/storage"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for storage plugin parameters.
# @PRE: None.
# @POST: Returns a dictionary representing the JSON schema.
# @RETURN: Dict[str, Any] - JSON schema.
def get_schema(self) -> Dict[str, Any]:
with belief_scope("StoragePlugin:get_schema"):
return {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [c.value for c in FileCategory],
"title": "Category"
}
},
"required": ["category"]
}
# [/DEF:get_schema:Function]
# [DEF:execute:Function]
# @PURPOSE: Executes storage-related tasks (placeholder for PluginBase compliance).
# @PRE: params must match the plugin schema.
# @POST: Task is executed and logged.
async def execute(self, params: Dict[str, Any]):
with belief_scope("StoragePlugin:execute"):
logger.info(f"[StoragePlugin][Action] Executing with params: {params}")
# [/DEF:execute:Function]
# [DEF:get_storage_root:Function]
# @PURPOSE: Resolves the absolute path to the storage root.
# @PRE: Settings must define a storage root path.
# @POST: Returns a Path object representing the storage root.
def get_storage_root(self) -> Path:
with belief_scope("StoragePlugin:get_storage_root"):
config_manager = get_config_manager()
global_settings = config_manager.get_config().settings
# Use storage.root_path as the source of truth for storage UI
root = Path(global_settings.storage.root_path)
if not root.is_absolute():
# Resolve relative to the backend directory
# Path(__file__) is backend/src/plugins/storage/plugin.py
# parents[3] is the project root (ss-tools)
# We need to ensure it's relative to where backend/ is
project_root = Path(__file__).parents[3]
root = (project_root / root).resolve()
return root
# [/DEF:get_storage_root:Function]
# [DEF:resolve_path:Function]
# @PURPOSE: Resolves a dynamic path pattern using provided variables.
# @PARAM: pattern (str) - The path pattern to resolve.
# @PARAM: variables (Dict[str, str]) - Variables to substitute in the pattern.
# @PRE: pattern must be a valid format string.
# @POST: Returns the resolved path string.
# @RETURN: str - The resolved path.
def resolve_path(self, pattern: str, variables: Dict[str, str]) -> str:
with belief_scope("StoragePlugin:resolve_path"):
# Add common variables
vars_with_defaults = {
"timestamp": datetime.now().strftime("%Y%m%dT%H%M%S"),
**variables
}
try:
resolved = pattern.format(**vars_with_defaults)
# Clean up any double slashes or leading/trailing slashes for relative path
return os.path.normpath(resolved).strip("/")
except KeyError as e:
logger.warning(f"[StoragePlugin][Coherence:Failed] Missing variable for path resolution: {e}")
# Fallback to literal pattern if formatting fails partially (or handle as needed)
return pattern.replace("{", "").replace("}", "")
# [/DEF:resolve_path:Function]
# [DEF:ensure_directories:Function]
# @PURPOSE: Creates the storage root and category subdirectories if they don't exist.
# @PRE: Storage root must be resolvable.
# @POST: Directories are created on the filesystem.
# @SIDE_EFFECT: Creates directories on the filesystem.
def ensure_directories(self):
with belief_scope("StoragePlugin:ensure_directories"):
root = self.get_storage_root()
for category in FileCategory:
# Use singular name for consistency with BackupPlugin and GitService
path = root / category.value
path.mkdir(parents=True, exist_ok=True)
logger.debug(f"[StoragePlugin][Action] Ensured directory: {path}")
# [/DEF:ensure_directories:Function]
# [DEF:validate_path:Function]
# @PURPOSE: Prevents path traversal attacks by ensuring the path is within the storage root.
# @PRE: path must be a Path object.
# @POST: Returns the resolved absolute path if valid, otherwise raises ValueError.
def validate_path(self, path: Path) -> Path:
with belief_scope("StoragePlugin:validate_path"):
root = self.get_storage_root().resolve()
resolved = path.resolve()
try:
resolved.relative_to(root)
except ValueError:
logger.error(f"[StoragePlugin][Coherence:Failed] Path traversal detected: {resolved} is not under {root}")
raise ValueError("Access denied: Path is outside of storage root.")
return resolved
# [/DEF:validate_path:Function]
# [DEF:list_files:Function]
# @PURPOSE: Lists all files and directories in a specific category and subpath.
# @PARAM: category (Optional[FileCategory]) - The category to list.
# @PARAM: subpath (Optional[str]) - Nested path within the category.
# @PRE: Storage root must exist.
# @POST: Returns a list of StoredFile objects.
# @RETURN: List[StoredFile] - List of file and directory metadata objects.
def list_files(self, category: Optional[FileCategory] = None, subpath: Optional[str] = None) -> List[StoredFile]:
with belief_scope("StoragePlugin:list_files"):
root = self.get_storage_root()
logger.info(f"[StoragePlugin][Action] Listing files in root: {root}, category: {category}, subpath: {subpath}")
files = []
categories = [category] if category else list(FileCategory)
for cat in categories:
# Scan the category subfolder + optional subpath
base_dir = root / cat.value
if subpath:
target_dir = self.validate_path(base_dir / subpath)
else:
target_dir = base_dir
if not target_dir.exists():
continue
logger.debug(f"[StoragePlugin][Action] Scanning directory: {target_dir}")
# Use os.scandir for better performance and to distinguish files vs dirs
with os.scandir(target_dir) as it:
for entry in it:
# Skip logs
if "Logs" in entry.path:
continue
stat = entry.stat()
is_dir = entry.is_dir()
files.append(StoredFile(
name=entry.name,
path=str(Path(entry.path).relative_to(root)),
size=stat.st_size if not is_dir else 0,
created_at=datetime.fromtimestamp(stat.st_ctime),
category=cat,
mime_type="directory" if is_dir else None
))
# Sort: directories first, then by name
return sorted(files, key=lambda x: (x.mime_type != "directory", x.name))
# [/DEF:list_files:Function]
# [DEF:save_file:Function]
# @PURPOSE: Saves an uploaded file to the specified category and optional subpath.
# @PARAM: file (UploadFile) - The uploaded file.
# @PARAM: category (FileCategory) - The target category.
# @PARAM: subpath (Optional[str]) - The target subpath.
# @PRE: file must be a valid UploadFile; category must be valid.
# @POST: File is written to disk and metadata is returned.
# @RETURN: StoredFile - Metadata of the saved file.
# @SIDE_EFFECT: Writes file to disk.
async def save_file(self, file: UploadFile, category: FileCategory, subpath: Optional[str] = None) -> StoredFile:
with belief_scope("StoragePlugin:save_file"):
root = self.get_storage_root()
dest_dir = root / category.value
if subpath:
dest_dir = dest_dir / subpath
dest_dir.mkdir(parents=True, exist_ok=True)
dest_path = self.validate_path(dest_dir / file.filename)
with dest_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
stat = dest_path.stat()
return StoredFile(
name=dest_path.name,
path=str(dest_path.relative_to(root)),
size=stat.st_size,
created_at=datetime.fromtimestamp(stat.st_ctime),
category=category,
mime_type=file.content_type
)
# [/DEF:save_file:Function]
# [DEF:delete_file:Function]
# @PURPOSE: Deletes a file or directory from the specified category and path.
# @PARAM: category (FileCategory) - The category.
# @PARAM: path (str) - The relative path of the file or directory.
# @PRE: path must belong to the specified category and exist on disk.
# @POST: The file or directory is removed from disk.
# @SIDE_EFFECT: Removes item from disk.
def delete_file(self, category: FileCategory, path: str):
with belief_scope("StoragePlugin:delete_file"):
root = self.get_storage_root()
# path is relative to root, but we ensure it starts with category
full_path = self.validate_path(root / path)
if not str(Path(path)).startswith(category.value):
raise ValueError(f"Path {path} does not belong to category {category}")
if full_path.exists():
if full_path.is_dir():
shutil.rmtree(full_path)
else:
full_path.unlink()
logger.info(f"[StoragePlugin][Action] Deleted: {full_path}")
else:
raise FileNotFoundError(f"Item {path} not found")
# [/DEF:delete_file:Function]
# [DEF:get_file_path:Function]
# @PURPOSE: Returns the absolute path of a file for download.
# @PARAM: category (FileCategory) - The category.
# @PARAM: path (str) - The relative path of the file.
# @PRE: path must belong to the specified category and be a file.
# @POST: Returns the absolute Path to the file.
# @RETURN: Path - Absolute path to the file.
def get_file_path(self, category: FileCategory, path: str) -> Path:
with belief_scope("StoragePlugin:get_file_path"):
root = self.get_storage_root()
file_path = self.validate_path(root / path)
if not str(Path(path)).startswith(category.value):
raise ValueError(f"Path {path} does not belong to category {category}")
if not file_path.exists() or file_path.is_dir():
raise FileNotFoundError(f"File {path} not found")
return file_path
# [/DEF:get_file_path:Function]
# [/DEF:StoragePlugin:Class]
# [/DEF:StoragePlugin:Module]

View File

@@ -0,0 +1,413 @@
# [DEF:backend.src.services.git_service:Module]
#
# @SEMANTICS: git, service, gitpython, repository, version_control
# @PURPOSE: Core Git logic using GitPython to manage dashboard repositories.
# @LAYER: Service
# @RELATION: INHERITS_FROM -> None
# @RELATION: USED_BY -> src.api.routes.git
# @RELATION: USED_BY -> src.plugins.git_plugin
#
# @INVARIANT: All Git operations must be performed on a valid local directory.
import os
import shutil
import httpx
from git import Repo, RemoteProgress
from fastapi import HTTPException
from typing import List, Optional
from datetime import datetime
from src.core.logger import logger, belief_scope
from src.models.git import GitProvider
# [DEF:GitService:Class]
# @PURPOSE: Wrapper for GitPython operations with semantic logging and error handling.
class GitService:
"""
Wrapper for GitPython operations.
"""
# [DEF:__init__:Function]
# @PURPOSE: Initializes the GitService with a base path for repositories.
# @PARAM: base_path (str) - Root directory for all Git clones.
# @PRE: base_path is a valid string path.
# @POST: GitService is initialized; base_path directory exists.
def __init__(self, base_path: str = "git_repos"):
with belief_scope("GitService.__init__"):
# Resolve relative to the backend directory
# Path(__file__) is backend/src/services/git_service.py
# parents[2] is backend/
from pathlib import Path
backend_root = Path(__file__).parents[2]
self.base_path = str((backend_root / base_path).resolve())
if not os.path.exists(self.base_path):
os.makedirs(self.base_path)
# [/DEF:__init__:Function]
# [DEF:_get_repo_path:Function]
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @PRE: dashboard_id is an integer.
# @POST: Returns the absolute or relative path to the dashboard's repo.
# @RETURN: str
def _get_repo_path(self, dashboard_id: int) -> str:
with belief_scope("GitService._get_repo_path"):
return os.path.join(self.base_path, str(dashboard_id))
# [/DEF:_get_repo_path:Function]
# [DEF:init_repo:Function]
# @PURPOSE: Initialize or clone a repository for a dashboard.
# @PARAM: dashboard_id (int)
# @PARAM: remote_url (str)
# @PARAM: pat (str) - Personal Access Token for authentication.
# @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided.
# @POST: Repository is cloned or opened at the local path.
# @RETURN: Repo - GitPython Repo object.
def init_repo(self, dashboard_id: int, remote_url: str, pat: str) -> Repo:
with belief_scope("GitService.init_repo"):
repo_path = self._get_repo_path(dashboard_id)
# Inject PAT into remote URL if needed
if pat and "://" in remote_url:
proto, rest = remote_url.split("://", 1)
auth_url = f"{proto}://oauth2:{pat}@{rest}"
else:
auth_url = remote_url
if os.path.exists(repo_path):
logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}")
return Repo(repo_path)
logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}")
return Repo.clone_from(auth_url, repo_path)
# [/DEF:init_repo:Function]
# [DEF:get_repo:Function]
# @PURPOSE: Get Repo object for a dashboard.
# @PRE: Repository must exist on disk for the given dashboard_id.
# @POST: Returns a GitPython Repo instance for the dashboard.
# @RETURN: Repo
def get_repo(self, dashboard_id: int) -> Repo:
with belief_scope("GitService.get_repo"):
repo_path = self._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
logger.error(f"[get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist")
raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found")
try:
return Repo(repo_path)
except Exception as e:
logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
# [/DEF:get_repo:Function]
# [DEF:list_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository.
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a list of branch metadata dictionaries.
# @RETURN: List[dict]
def list_branches(self, dashboard_id: int) -> List[dict]:
with belief_scope("GitService.list_branches"):
repo = self.get_repo(dashboard_id)
logger.info(f"[list_branches][Action] Listing branches for {dashboard_id}. Refs: {repo.refs}")
branches = []
# Add existing refs
for ref in repo.refs:
try:
# Strip prefixes for UI
name = ref.name.replace('refs/heads/', '').replace('refs/remotes/origin/', '')
# Avoid duplicates (e.g. local and remote with same name)
if any(b['name'] == name for b in branches):
continue
branches.append({
"name": name,
"commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000",
"is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False,
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
})
except Exception as e:
logger.warning(f"[list_branches][Action] Skipping ref {ref}: {e}")
# Ensure the current active branch is in the list even if it has no commits or refs
try:
active_name = repo.active_branch.name
if not any(b['name'] == active_name for b in branches):
branches.append({
"name": active_name,
"commit_hash": "0000000",
"is_remote": False,
"last_updated": datetime.utcnow()
})
except Exception as e:
logger.warning(f"[list_branches][Action] Could not determine active branch: {e}")
# If everything else failed and list is still empty, add default
if not branches:
branches.append({
"name": "main",
"commit_hash": "0000000",
"is_remote": False,
"last_updated": datetime.utcnow()
})
return branches
# [/DEF:list_branches:Function]
# [DEF:create_branch:Function]
# @PURPOSE: Create a new branch from an existing one.
# @PARAM: name (str) - New branch name.
# @PARAM: from_branch (str) - Source branch.
# @PRE: Repository exists; name is valid; from_branch exists or repo is empty.
# @POST: A new branch is created in the repository.
def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
with belief_scope("GitService.create_branch"):
repo = self.get_repo(dashboard_id)
logger.info(f"[create_branch][Action] Creating branch {name} from {from_branch}")
# Handle empty repository case (no commits)
if not repo.heads and not repo.remotes:
logger.warning(f"[create_branch][Action] Repository is empty. Creating initial commit to enable branching.")
readme_path = os.path.join(repo.working_dir, "README.md")
if not os.path.exists(readme_path):
with open(readme_path, "w") as f:
f.write(f"# Dashboard {dashboard_id}\nGit repository for Superset dashboard integration.")
repo.index.add(["README.md"])
repo.index.commit("Initial commit")
# Verify source branch exists
try:
repo.commit(from_branch)
except:
logger.warning(f"[create_branch][Action] Source branch {from_branch} not found, using HEAD")
from_branch = repo.head
try:
new_branch = repo.create_head(name, from_branch)
return new_branch
except Exception as e:
logger.error(f"[create_branch][Coherence:Failed] {e}")
raise
# [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function]
# @PURPOSE: Switch to a specific branch.
# @PRE: Repository exists and the specified branch name exists.
# @POST: The repository working directory is updated to the specified branch.
def checkout_branch(self, dashboard_id: int, name: str):
with belief_scope("GitService.checkout_branch"):
repo = self.get_repo(dashboard_id)
logger.info(f"[checkout_branch][Action] Checking out branch {name}")
repo.git.checkout(name)
# [/DEF:checkout_branch:Function]
# [DEF:commit_changes:Function]
# @PURPOSE: Stage and commit changes.
# @PARAM: message (str) - Commit message.
# @PARAM: files (List[str]) - Optional list of specific files to stage.
# @PRE: Repository exists and has changes (dirty) or files are specified.
# @POST: Changes are staged and a new commit is created.
def commit_changes(self, dashboard_id: int, message: str, files: List[str] = None):
with belief_scope("GitService.commit_changes"):
repo = self.get_repo(dashboard_id)
# Check if there are any changes to commit
if not repo.is_dirty(untracked_files=True) and not files:
logger.info(f"[commit_changes][Action] No changes to commit for dashboard {dashboard_id}")
return
if files:
logger.info(f"[commit_changes][Action] Staging files: {files}")
repo.index.add(files)
else:
logger.info("[commit_changes][Action] Staging all changes")
repo.git.add(A=True)
repo.index.commit(message)
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
# [/DEF:commit_changes:Function]
# [DEF:push_changes:Function]
# @PURPOSE: Push local commits to remote.
# @PRE: Repository exists and has an 'origin' remote.
# @POST: Local branch commits are pushed to origin.
def push_changes(self, dashboard_id: int):
with belief_scope("GitService.push_changes"):
repo = self.get_repo(dashboard_id)
# Ensure we have something to push
if not repo.heads:
logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}")
return
try:
origin = repo.remote(name='origin')
except ValueError:
logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
# Check if current branch has an upstream
try:
current_branch = repo.active_branch
logger.info(f"[push_changes][Action] Pushing branch {current_branch.name} to origin")
# Using a timeout for network operations
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
for info in push_info:
if info.flags & info.ERROR:
logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}")
raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}")
except Exception as e:
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}")
# [/DEF:push_changes:Function]
# [DEF:pull_changes:Function]
# @PURPOSE: Pull changes from remote.
# @PRE: Repository exists and has an 'origin' remote.
# @POST: Changes from origin are pulled and merged into the active branch.
def pull_changes(self, dashboard_id: int):
with belief_scope("GitService.pull_changes"):
repo = self.get_repo(dashboard_id)
try:
origin = repo.remote(name='origin')
logger.info("[pull_changes][Action] Pulling changes from origin")
fetch_info = origin.pull()
for info in fetch_info:
if info.flags & info.ERROR:
logger.error(f"[pull_changes][Coherence:Failed] Error pulling ref {info.ref}: {info.note}")
raise Exception(f"Git pull error for {info.ref}: {info.note}")
except ValueError:
logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
except Exception as e:
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}")
# [/DEF:pull_changes:Function]
# [DEF:get_status:Function]
# @PURPOSE: Get current repository status (dirty files, untracked, etc.)
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a dictionary representing the Git status.
# @RETURN: dict
def get_status(self, dashboard_id: int) -> dict:
with belief_scope("GitService.get_status"):
repo = self.get_repo(dashboard_id)
# Handle empty repository (no commits)
has_commits = False
try:
repo.head.commit
has_commits = True
except (ValueError, Exception):
has_commits = False
return {
"is_dirty": repo.is_dirty(untracked_files=True),
"untracked_files": repo.untracked_files,
"modified_files": [item.a_path for item in repo.index.diff(None)],
"staged_files": [item.a_path for item in repo.index.diff("HEAD")] if has_commits else [],
"current_branch": repo.active_branch.name
}
# [/DEF:get_status:Function]
# [DEF:get_diff:Function]
# @PURPOSE: Generate diff for a file or the whole repository.
# @PARAM: file_path (str) - Optional specific file.
# @PARAM: staged (bool) - Whether to show staged changes.
# @PRE: Repository for dashboard_id exists.
# @POST: Returns the diff text as a string.
# @RETURN: str
def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str:
with belief_scope("GitService.get_diff"):
repo = self.get_repo(dashboard_id)
diff_args = []
if staged:
diff_args.append("--staged")
if file_path:
return repo.git.diff(*diff_args, "--", file_path)
return repo.git.diff(*diff_args)
# [/DEF:get_diff:Function]
# [DEF:get_commit_history:Function]
# @PURPOSE: Retrieve commit history for a repository.
# @PARAM: limit (int) - Max number of commits to return.
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a list of dictionaries for each commit in history.
# @RETURN: List[dict]
def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]:
with belief_scope("GitService.get_commit_history"):
repo = self.get_repo(dashboard_id)
commits = []
try:
# Check if there are any commits at all
if not repo.heads and not repo.remotes:
return []
for commit in repo.iter_commits(max_count=limit):
commits.append({
"hash": commit.hexsha,
"author": commit.author.name,
"email": commit.author.email,
"timestamp": datetime.fromtimestamp(commit.committed_date),
"message": commit.message.strip(),
"files_changed": list(commit.stats.files.keys())
})
except Exception as e:
logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}")
return []
return commits
# [/DEF:get_commit_history:Function]
# [DEF:test_connection:Function]
# @PURPOSE: Test connection to Git provider using PAT.
# @PARAM: provider (GitProvider)
# @PARAM: url (str)
# @PARAM: pat (str)
# @PRE: provider is valid; url is a valid HTTP(S) URL; pat is provided.
# @POST: Returns True if connection to the provider's API succeeds.
# @RETURN: bool
async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool:
with belief_scope("GitService.test_connection"):
# Check for offline mode or local-only URLs
if ".local" in url or "localhost" in url:
logger.info("[test_connection][Action] Local/Offline mode detected for URL")
return True
if not url.startswith(('http://', 'https://')):
logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}")
return False
if not pat or not pat.strip():
logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty")
return False
pat = pat.strip()
try:
async with httpx.AsyncClient() as client:
if provider == GitProvider.GITHUB:
headers = {"Authorization": f"token {pat}"}
api_url = "https://api.github.com/user" if "github.com" in url else f"{url.rstrip('/')}/api/v3/user"
resp = await client.get(api_url, headers=headers)
elif provider == GitProvider.GITLAB:
headers = {"PRIVATE-TOKEN": pat}
api_url = f"{url.rstrip('/')}/api/v4/user"
resp = await client.get(api_url, headers=headers)
elif provider == GitProvider.GITEA:
headers = {"Authorization": f"token {pat}"}
api_url = f"{url.rstrip('/')}/api/v1/user"
resp = await client.get(api_url, headers=headers)
else:
return False
if resp.status_code != 200:
logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
return resp.status_code == 200
except Exception as e:
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
return False
# [/DEF:test_connection:Function]
# [/DEF:GitService:Class]
# [/DEF:backend.src.services.git_service:Module]

View File

@@ -13,7 +13,6 @@ from typing import List, Dict
from backend.src.core.logger import belief_scope from backend.src.core.logger import belief_scope
from backend.src.core.superset_client import SupersetClient from backend.src.core.superset_client import SupersetClient
from backend.src.core.utils.matching import suggest_mappings from backend.src.core.utils.matching import suggest_mappings
from superset_tool.models import SupersetConfig
# [/SECTION] # [/SECTION]
# [DEF:MappingService:Class] # [DEF:MappingService:Class]
@@ -43,17 +42,7 @@ class MappingService:
if not env: if not env:
raise ValueError(f"Environment {env_id} not found") raise ValueError(f"Environment {env_id} not found")
superset_config = SupersetConfig( return SupersetClient(env)
env=env.name,
base_url=env.url,
auth={
"provider": "db",
"username": env.username,
"password": env.password,
"refresh": "false"
}
)
return SupersetClient(superset_config)
# [/DEF:_get_client:Function] # [/DEF:_get_client:Function]
# [DEF:get_suggestions:Function] # [DEF:get_suggestions:Function]

Binary file not shown.

View File

@@ -1,5 +1,5 @@
import pytest import pytest
from backend.src.core.logger import belief_scope, logger from src.core.logger import belief_scope, logger
# [DEF:test_belief_scope_logs_entry_action_exit:Function] # [DEF:test_belief_scope_logs_entry_action_exit:Function]

View File

@@ -1,62 +1,21 @@
import pytest import pytest
from superset_tool.models import SupersetConfig from src.core.config_models import Environment
from superset_tool.utils.logger import belief_scope from src.core.logger import belief_scope
# [DEF:test_superset_config_url_normalization:Function] # [DEF:test_environment_model:Function]
# @PURPOSE: Tests that SupersetConfig correctly normalizes the base URL. # @PURPOSE: Tests that Environment model correctly stores values.
# @PRE: SupersetConfig class is available. # @PRE: Environment class is available.
# @POST: URL normalization is verified. # @POST: Values are verified.
def test_superset_config_url_normalization(): def test_environment_model():
with belief_scope("test_superset_config_url_normalization"): with belief_scope("test_environment_model"):
auth = { env = Environment(
"provider": "db", id="test-id",
"username": "admin", name="test-env",
"password": "password", url="http://localhost:8088/api/v1",
"refresh": "token" username="admin",
} password="password"
# Test with /api/v1 already present
config = SupersetConfig(
env="dev",
base_url="http://localhost:8088/api/v1",
auth=auth
) )
assert config.base_url == "http://localhost:8088/api/v1" assert env.id == "test-id"
assert env.name == "test-env"
# Test without /api/v1 assert env.url == "http://localhost:8088/api/v1"
config = SupersetConfig( # [/DEF:test_environment_model:Function]
env="dev",
base_url="http://localhost:8088",
auth=auth
)
assert config.base_url == "http://localhost:8088/api/v1"
# Test with trailing slash
config = SupersetConfig(
env="dev",
base_url="http://localhost:8088/",
auth=auth
)
assert config.base_url == "http://localhost:8088/api/v1"
# [/DEF:test_superset_config_url_normalization:Function]
# [DEF:test_superset_config_invalid_url:Function]
# @PURPOSE: Tests that SupersetConfig raises ValueError for invalid URLs.
# @PRE: SupersetConfig class is available.
# @POST: ValueError is raised for invalid URLs.
def test_superset_config_invalid_url():
with belief_scope("test_superset_config_invalid_url"):
auth = {
"provider": "db",
"username": "admin",
"password": "password",
"refresh": "token"
}
with pytest.raises(ValueError, match="Must start with http:// or https://"):
SupersetConfig(
env="dev",
base_url="localhost:8088",
auth=auth
)
# [/DEF:test_superset_config_invalid_url:Function]

View File

@@ -0,0 +1,55 @@
slice_name: "FI-0083 \u0421\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043A\u0430\
\ \u043F\u043E \u0414\u0417/\u041F\u0414\u0417"
description: null
certified_by: null
certification_details: null
viz_type: pivot_table_v2
params:
datasource: 859__table
viz_type: pivot_table_v2
slice_id: 4019
groupbyColumns:
- dt
groupbyRows:
- counterparty_search_name
- attribute
time_grain_sqla: P1M
temporal_columns_lookup:
dt: true
metrics:
- m_debt_amount
- m_overdue_amount
metricsLayout: COLUMNS
adhoc_filters:
- clause: WHERE
comparator: No filter
expressionType: SIMPLE
operator: TEMPORAL_RANGE
subject: dt
row_limit: '90000'
order_desc: false
aggregateFunction: Sum
combineMetric: true
valueFormat: SMART_NUMBER
date_format: smart_date
rowOrder: key_a_to_z
colOrder: key_a_to_z
value_font_size: 12
header_font_size: 12
label_align: left
column_config:
m_debt_amount:
d3NumberFormat: ',d'
m_overdue_amount:
d3NumberFormat: ',d'
conditional_formatting: []
extra_form_data: {}
dashboards:
- 184
query_context: '{"datasource":{"id":859,"type":"table"},"force":false,"queries":[{"filters":[{"col":"dt","op":"TEMPORAL_RANGE","val":"No
filter"}],"extras":{"having":"","where":""},"applied_time_extras":{},"columns":[{"timeGrain":"P1M","columnType":"BASE_AXIS","sqlExpression":"dt","label":"dt","expressionType":"SQL"},"counterparty_search_name","attribute"],"metrics":["m_debt_amount","m_overdue_amount"],"orderby":[["m_debt_amount",true]],"annotation_layers":[],"row_limit":90000,"series_limit":0,"order_desc":false,"url_params":{},"custom_params":{},"custom_form_data":{}}],"form_data":{"datasource":"859__table","viz_type":"pivot_table_v2","slice_id":4019,"groupbyColumns":["dt"],"groupbyRows":["counterparty_search_name","attribute"],"time_grain_sqla":"P1M","temporal_columns_lookup":{"dt":true},"metrics":["m_debt_amount","m_overdue_amount"],"metricsLayout":"COLUMNS","adhoc_filters":[{"clause":"WHERE","comparator":"No
filter","expressionType":"SIMPLE","operator":"TEMPORAL_RANGE","subject":"dt"}],"row_limit":"90000","order_desc":false,"aggregateFunction":"Sum","combineMetric":true,"valueFormat":"SMART_NUMBER","date_format":"smart_date","rowOrder":"key_a_to_z","colOrder":"key_a_to_z","value_font_size":12,"header_font_size":12,"label_align":"left","column_config":{"m_debt_amount":{"d3NumberFormat":",d"},"m_overdue_amount":{"d3NumberFormat":",d"}},"conditional_formatting":[],"extra_form_data":{},"dashboards":[184],"force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
cache_timeout: null
uuid: 9c293065-73e2-4d9b-a175-d188ff8ef575
version: 1.0.0
dataset_uuid: 9e645dc0-da25-4f61-9465-6e649b0bc4b1

View File

@@ -0,0 +1,13 @@
database_name: Prod Clickhouse
sqlalchemy_uri: clickhousedb+connect://viz_superset_click_prod:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm
cache_timeout: null
expose_in_sqllab: true
allow_run_async: false
allow_ctas: false
allow_cvas: false
allow_dml: true
allow_file_upload: false
extra:
allows_virtual_table_explore: true
uuid: 97aced68-326a-4094-b381-27980560efa9
version: 1.0.0

View File

@@ -0,0 +1,119 @@
table_name: "FI-0080-06 \u041A\u0430\u043B\u0435\u043D\u0434\u0430\u0440\u044C (\u041E\
\u0431\u0449\u0438\u0439 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\
)"
main_dttm_col: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
schema: dm_view
sql: "-- [HEADER]\r\n-- [\u041D\u0410\u0417\u041D\u0410\u0427\u0415\u041D\u0418\u0415\
]: \u041F\u043E\u043B\u0443\u0447\u0435\u043D\u0438\u0435 \u0434\u0438\u0430\u043F\
\u0430\u0437\u043E\u043D\u0430 \u0434\u0430\u0442 \u0434\u043B\u044F \u043E\u0442\
\u0447\u0435\u0442\u0430 \u043E \u0437\u0430\u0434\u043E\u043B\u0436\u0435\u043D\
\u043D\u043E\u0441\u0442\u044F\u0445 \u043F\u043E \u043E\u0431\u043E\u0440\u043E\
\u0442\u043D\u044B\u043C \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043C\r\
\n-- [\u041A\u041B\u042E\u0427\u0415\u0412\u042B\u0415 \u041A\u041E\u041B\u041E\u041D\
\u041A\u0418]:\r\n-- - from_dt_txt: \u041D\u0430\u0447\u0430\u043B\u044C\u043D\
\u0430\u044F \u0434\u0430\u0442\u0430 \u0432 \u0444\u043E\u0440\u043C\u0430\u0442\
\u0435 DD.MM.YYYY\r\n-- - to_dt_txt: \u041A\u043E\u043D\u0435\u0447\u043D\u0430\
\u044F \u0434\u0430\u0442\u0430 \u0432 \u0444\u043E\u0440\u043C\u0430\u0442\u0435\
\ DD.MM.YYYY\r\n-- [JINJA \u041F\u0410\u0420\u0410\u041C\u0415\u0422\u0420\u042B\
]:\r\n-- - {{ filter_values(\"yes_no_check\") }}: \u0424\u0438\u043B\u044C\u0442\
\u0440 \"\u0414\u0430/\u041D\u0435\u0442\" \u0434\u043B\u044F \u043E\u0433\u0440\
\u0430\u043D\u0438\u0447\u0435\u043D\u0438\u044F \u0432\u044B\u0431\u043E\u0440\u043A\
\u0438 \u043F\u043E \u0434\u0430\u0442\u0435\r\n-- [\u041B\u041E\u0413\u0418\u041A\
\u0410]: \u041E\u043F\u0440\u0435\u0434\u0435\u043B\u044F\u0435\u0442 \u043F\u043E\
\u0440\u043E\u0433\u043E\u0432\u0443\u044E \u0434\u0430\u0442\u0443 \u0432 \u0437\
\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\u0438 \u043E\u0442 \u0442\
\u0435\u043A\u0443\u0449\u0435\u0433\u043E \u0434\u043D\u044F \u043C\u0435\u0441\
\u044F\u0446\u0430 \u0438 \u0444\u0438\u043B\u044C\u0442\u0440\u0443\u0435\u0442\
\ \u0434\u0430\u043D\u043D\u044B\u0435\r\n\r\nWITH date_threshold AS (\r\n SELECT\
\ \r\n -- \u041E\u043F\u0440\u0435\u0434\u0435\u043B\u044F\u0435\u043C \u043F\
\u043E\u0440\u043E\u0433\u043E\u0432\u0443\u044E \u0434\u0430\u0442\u0443 \u0432\
\ \u0437\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\u0438 \u043E\u0442\
\ \u0442\u0435\u043A\u0443\u0449\u0435\u0433\u043E \u0434\u043D\u044F \r\n \
\ CASE \r\n WHEN toDayOfMonth(now()) <= 10 THEN \r\n \
\ toStartOfMonth(dateSub(MONTH, 1, now())) \r\n ELSE \r\n \
\ toStartOfMonth(now()) \r\n END AS cutoff_date \r\n),\r\nfiltered_dates\
\ AS (\r\n SELECT \r\n dt,\r\n formatDateTime(dt, '%d.%m.%Y') AS\
\ from_dt_txt,\r\n formatDateTime(dt, '%d.%m.%Y') AS to_dt_txt\r\n \
\ --dt as from_dt_txt,\r\n -- dt as to_dt_txt\r\n FROM dm_view.account_debt_for_working_capital_final\r\
\n WHERE 1=1\r\n -- \u0411\u0435\u0437\u043E\u043F\u0430\u0441\u043D\u0430\
\u044F \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0430 \u0444\u0438\u043B\u044C\
\u0442\u0440\u0430\r\n {% if filter_values(\"yes_no_check\") | length !=\
\ 0 %}\r\n {% if filter_values(\"yes_no_check\")[0] == \"\u0414\u0430\
\" %}\r\n AND dt < (SELECT cutoff_date FROM date_threshold)\r\n \
\ {% endif %}\r\n {% endif %}\r\n)\r\nSELECT \r\ndt,\r\n from_dt_txt,\r\
\n to_dt_txt,\r\n formatDateTime(toLastDayOfMonth(dt), '%d.%m.%Y') as last_day_of_month_dt_txt\r\
\nFROM \r\n filtered_dates\r\nGROUP BY \r\n dt, from_dt_txt, to_dt_txt\r\n\
ORDER BY \r\n dt DESC"
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
uuid: fca62707-6947-4440-a16b-70cb6a5cea5b
metrics:
- metric_name: max_date
verbose_name: max_date
metric_type: count
expression: max(dt)
description: null
d3format: null
currency: null
extra:
warning_markdown: ''
warning_text: null
columns:
- column_name: from_dt_txt
verbose_name: null
is_dttm: true
is_active: true
type: String
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: '%Y'
extra: {}
- column_name: dt
verbose_name: null
is_dttm: true
is_active: true
type: Date
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra: {}
- column_name: last_day_of_month_dt_txt
verbose_name: null
is_dttm: false
is_active: true
type: String
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra: {}
- column_name: to_dt_txt
verbose_name: null
is_dttm: true
is_active: true
type: String
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra: {}
version: 1.0.0
database_uuid: 97aced68-326a-4094-b381-27980560efa9

View File

@@ -0,0 +1,190 @@
table_name: "FI-0090 \u0421\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043A\u0430\
\ \u043F\u043E \u0414\u0417/\u041F\u0414\u0417"
main_dttm_col: dt
description: null
default_endpoint: null
offset: 0
cache_timeout: null
schema: dm_view
sql: "-- [JINJA_BLOCK] \u0426\u0435\u043D\u0442\u0440\u0430\u043B\u0438\u0437\u043E\
\u0432\u0430\u043D\u043D\u043E\u0435 \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0435\
\u043D\u0438\u0435 \u0432\u0441\u0435\u0445 Jinja \u043F\u0435\u0440\u0435\u043C\
\u0435\u043D\u043D\u044B\u0445\r\n{% set raw_to = filter_values('last_day_of_month_dt_txt')[0]\
\ \r\n if filter_values('last_day_of_month_dt_txt') else '01.05.2025'\
\ %}\r\n\r\n{# \u0440\u0430\u0437\u0431\u0438\u0432\u0430\u0435\u043C \xABDD.MM.YYYY\xBB\
\ \u043D\u0430 \u0447\u0430\u0441\u0442\u0438 #}\r\n{% set to_parts = raw_to.split('.')\
\ %}\r\n\r\n{# \u0441\u043E\u0431\u0438\u0440\u0430\u0435\u043C ISO\u2011\u0441\u0442\
\u0440\u043E\u043A\u0443 \xABYYYY-MM-DD\xBB #}\r\n{% set to_dt = to_parts[2] \
\ ~ '-' ~ to_parts[1] ~ '-' ~ to_parts[0] %}\r\n\r\nwith \r\ncp_relations_type\
\ AS (\r\n select * from ( SELECT \r\n ctd.counterparty_code AS counterparty_code,\r\
\n min(dt_from) as dt_from,\r\n max(dt_to) as dt_to,\r\n crt.relation_type_code\
\ || ' ' || crt.relation_type_name AS relation_type_code_name\r\n FROM\r\n \
\ dm_view.counterparty_td ctd\r\n JOIN dm_view.counterparty_relation_type_texts\
\ crt \r\n ON ctd.relation_type_code = crt.relation_type_code\r\n GROUP\
\ BY\r\n ctd.counterparty_code, ctd.counterparty_full_name,\r\n crt.relation_type_code,crt.relation_type_name)\r\
\n WHERE \r\n dt_from <= toDate('{{to_dt }}') AND \r\n \
\ dt_to >= toDate('{{to_dt }}')\r\n ),\r\nt_debt as \r\n(SELECT dt, \r\n\
counterparty_search_name,\r\ncp_relations_type.relation_type_code_name as relation_type_code_name,\r\
\nunit_balance_code || ' ' || unit_balance_name as unit_balance_code_name,\r\n'1.\
\ \u0421\u0443\u043C\u043C\u0430' as attribute,\r\nsum(debt_balance_subposition_no_revaluation_usd_amount)\
\ as debt_amount,\r\nsumIf(debt_balance_subposition_no_revaluation_usd_amount,dt_overdue\
\ < dt) as overdue_amount\r\nfrom dm_view.account_debt_for_working_capital t_debt\r\
\njoin cp_relations_type ON\r\ncp_relations_type.counterparty_code = t_debt.counterparty_code\r\
\nwhere dt = toLastDayOfMonth(dt)\r\nand match(general_ledger_account_code,'((62)|(60)|(76))')\r\
\nand debit_or_credit = 'S'\r\nand account_type = 'D'\r\nand dt between addMonths(toDate('{{to_dt\
\ }}'),-12) and toDate('{{to_dt }}')\r\ngroup by dt, counterparty_search_name,unit_balance_code_name,relation_type_code_name\r\
\n),\r\n\r\nt_transaction_count_base as \r\n(\r\nselect *,\r\ncp_relations_type.relation_type_code_name\
\ as relation_type_code_name,\r\nunit_balance_code || ' ' || unit_balance_name as\
\ unit_balance_code_name,\r\n case when dt_overdue<dt_clearing then\r\n \
\ dateDiff(day, dt_overdue, dt_clearing) \r\n else 0\r\n end\
\ as overdue_days\r\nfrom dm_view.accounting_documents_leading_to_debt t_docs\r\n\
join cp_relations_type ON\r\ncp_relations_type.counterparty_code = t_docs.counterparty_code\r\
\nwhere 1=1\r\n\r\nand match(general_ledger_account_code,'((62)|(60)|(76))')\r\n\
and debit_or_credit = 'S'\r\nand account_type = 'D'\r\n)\r\n\r\nselect * from t_debt\r\
\n\r\nunion all \r\n\r\nselect toLastDayOfMonth(dt_debt) as dt, \r\ncounterparty_search_name,\r\
\nrelation_type_code_name,\r\nunit_balance_code_name,\r\n'2. \u043A\u043E\u043B\u0438\
\u0447\u0435\u0441\u0442\u0432\u043E \u0442\u0440\u0430\u043D\u0437\u0430\u043A\u0446\
\u0438\u0439 \u0432 \u043C\u0435\u0441\u044F\u0446' as attribute,\r\ncount(1) as\
\ debt_amount,\r\nnull as overdue_amount\r\nfrom t_transaction_count_base\r\nwhere\
\ dt_debt between addMonths(toDate('{{to_dt }}'),-12) and toDate('{{to_dt }}')\r\
\ngroup by toLastDayOfMonth(dt_debt), \r\ncounterparty_search_name,\r\nrelation_type_code_name,\r\
\nunit_balance_code_name,attribute\r\n\r\nunion all \r\n\r\nselect toLastDayOfMonth(dt_clearing)\
\ as dt, \r\ncounterparty_search_name,\r\nrelation_type_code_name,\r\nunit_balance_code_name,\r\
\n'2. \u043A\u043E\u043B\u0438\u0447\u0435\u0441\u0442\u0432\u043E \u0442\u0440\u0430\
\u043D\u0437\u0430\u043A\u0446\u0438\u0439 \u0432 \u043C\u0435\u0441\u044F\u0446\
' as attribute,\r\nnull as debt_amount,\r\ncount(1) as overdue_amount\r\nfrom t_transaction_count_base\r\
\nwhere dt_clearing between addMonths(toDate('{{to_dt }}'),-12) and toDate('{{to_dt\
\ }}')\r\nand overdue_days > 0\r\ngroup by toLastDayOfMonth(dt_clearing), \r\ncounterparty_search_name,\r\
\nrelation_type_code_name,\r\nunit_balance_code_name,attribute\r\n\r\nunion all\
\ \r\n\r\nselect toLastDayOfMonth(dt_clearing) as dt, \r\ncounterparty_search_name,\r\
\nrelation_type_code_name,\r\nunit_balance_code_name,\r\nmultiIf(\r\noverdue_days\
\ < 30,'3. \u0434\u043E 30',\r\noverdue_days between 30 and 60, '4. \u043E\u0442\
\ 30 \u0434\u043E 60',\r\noverdue_days between 61 and 90, '5. \u043E\u0442 61 \u0434\
\u043E 90',\r\noverdue_days>90,'6. \u0431\u043E\u043B\u0435\u0435 90 \u0434\u043D\
',\r\nnull\r\n)\r\n as attribute,\r\nnull as debt_amount,\r\ncount(1) as overdue_amount\r\
\nfrom t_transaction_count_base\r\nwhere dt_clearing between addMonths(toDate('{{to_dt\
\ }}'),-12) and toDate('{{to_dt }}')\r\nand overdue_days > 0\r\ngroup by toLastDayOfMonth(dt_clearing),\
\ \r\ncounterparty_search_name,\r\nrelation_type_code_name,\r\nattribute,unit_balance_code_name,attribute\r\
\n"
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
uuid: 9e645dc0-da25-4f61-9465-6e649b0bc4b1
metrics:
- metric_name: m_debt_amount
verbose_name: "\u0414\u0417, $"
metric_type: count
expression: sum(debt_amount)
description: null
d3format: null
currency: null
extra:
warning_markdown: ''
warning_text: null
- metric_name: m_overdue_amount
verbose_name: "\u041F\u0414\u0417, $"
metric_type: null
expression: sum(overdue_amount)
description: null
d3format: null
currency: null
extra:
warning_markdown: ''
warning_text: null
columns:
- column_name: debt_amount
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(Decimal(38, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: overdue_amount
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(Decimal(38, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: dt
verbose_name: null
is_dttm: true
is_active: true
type: Nullable(Date)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: unit_balance_code_name
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(String)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: relation_type_code_name
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(String)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: counterparty_search_name
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(String)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: attribute
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(String)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
version: 1.0.0
database_uuid: 97aced68-326a-4094-b381-27980560efa9

View File

@@ -0,0 +1,3 @@
version: 1.0.0
type: Dashboard
timestamp: '2026-01-14T11:21:08.078620+00:00'

View File

@@ -13,7 +13,7 @@ The settings mechanism allows users to configure multiple Superset environments
Configuration is structured using Pydantic models in `backend/src/core/config_models.py`: Configuration is structured using Pydantic models in `backend/src/core/config_models.py`:
- `Environment`: Represents a Superset instance (URL, credentials). The `base_url` is automatically normalized to include the `/api/v1` suffix if missing. - `Environment`: Represents a Superset instance (URL, credentials). The `base_url` is automatically normalized to include the `/api/v1` suffix if missing.
- `GlobalSettings`: Global application parameters (e.g., `backup_path`). - `GlobalSettings`: Global application parameters (e.g., `storage.root_path`).
- `AppConfig`: The root configuration object. - `AppConfig`: The root configuration object.
### Configuration Manager ### Configuration Manager
@@ -43,4 +43,4 @@ The settings page is located at `frontend/src/pages/Settings.svelte`. It provide
Existing plugins and utilities use the `ConfigManager` to fetch configuration: Existing plugins and utilities use the `ConfigManager` to fetch configuration:
- `superset_tool/utils/init_clients.py`: Dynamically initializes Superset clients from the configured environments. - `superset_tool/utils/init_clients.py`: Dynamically initializes Superset clients from the configured environments.
- `BackupPlugin`: Uses the configured `backup_path` as the default storage location. - `BackupPlugin`: Uses the configured `storage.root_path` as the default storage location.

7
frontend/.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
build/
.svelte-kit/
.vite/
coverage/
*.min.js

26
frontend/.gitignore vendored Executable file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.svelte-kit
build
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

9
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
build/
.svelte-kit/
.vite/
coverage/
package-lock.json
yarn.lock
pnpm-lock.yaml

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -1,4 +1,5 @@
{ {
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"moduleResolution": "bundler", "moduleResolution": "bundler",
"target": "ESNext", "target": "ESNext",

View File

@@ -1,117 +0,0 @@
<!-- [DEF:App:Component] -->
<!--
@SEMANTICS: main, entrypoint, layout, navigation
@PURPOSE: The root component of the frontend application. Manages navigation and layout.
@LAYER: UI
@RELATION: DEPENDS_ON -> frontend/src/pages/Dashboard.svelte
@RELATION: DEPENDS_ON -> frontend/src/pages/Settings.svelte
@RELATION: DEPENDS_ON -> frontend/src/lib/stores.js
@INVARIANT: Navigation state must be persisted in the currentPage store.
-->
<script>
// [SECTION: IMPORTS]
import { get } from 'svelte/store';
import Dashboard from './pages/Dashboard.svelte';
import Settings from './pages/Settings.svelte';
import { selectedPlugin, selectedTask, currentPage } from './lib/stores.js';
import TaskRunner from './components/TaskRunner.svelte';
import DynamicForm from './components/DynamicForm.svelte';
import { api } from './lib/api.js';
import Toast from './components/Toast.svelte';
// [/SECTION]
// [DEF:handleFormSubmit:Function]
/**
* @purpose Handles form submission for task creation.
* @pre event.detail contains form parameters.
* @post Task is created and selectedTask is updated.
* @param {CustomEvent} event - The submit event from DynamicForm.
*/
async function handleFormSubmit(event) {
console.log("[App.handleFormSubmit][Action] Handling form submission for task creation.");
const params = event.detail;
try {
const plugin = get(selectedPlugin);
const task = await api.createTask(plugin.id, params);
selectedTask.set(task);
selectedPlugin.set(null);
console.log(`[App.handleFormSubmit][Coherence:OK] Task created id=${task.id}`);
} catch (error) {
console.error(`[App.handleFormSubmit][Coherence:Failed] Task creation failed error=${error}`);
}
}
// [/DEF:handleFormSubmit:Function]
// [DEF:navigate:Function]
/**
* @purpose Changes the current page and resets state.
* @pre Target page name is provided.
* @post currentPage store is updated and selection state is reset.
* @param {string} page - Target page name.
*/
function navigate(page) {
console.log(`[App.navigate][Action] Navigating to ${page}.`);
// Reset selection first
if (page !== get(currentPage)) {
selectedPlugin.set(null);
selectedTask.set(null);
}
// Then set page
currentPage.set(page);
}
// [/DEF:navigate:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<Toast />
<main class="bg-gray-50 min-h-screen">
<header class="bg-white shadow-md p-4 flex justify-between items-center">
<button
type="button"
class="text-3xl font-bold text-gray-800 focus:outline-none"
on:click={() => navigate('dashboard')}
>
Superset Tools
</button>
<nav class="space-x-4">
<button
type="button"
on:click={() => navigate('dashboard')}
class="text-gray-600 hover:text-blue-600 font-medium {$currentPage === 'dashboard' ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
Dashboard
</button>
<button
type="button"
on:click={() => navigate('settings')}
class="text-gray-600 hover:text-blue-600 font-medium {$currentPage === 'settings' ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
Settings
</button>
</nav>
</header>
<div class="p-4">
{#if $currentPage === 'settings'}
<Settings />
{:else if $selectedTask}
<TaskRunner />
<button on:click={() => selectedTask.set(null)} class="mt-4 bg-blue-500 text-white p-2 rounded">
Back to Task List
</button>
{:else if $selectedPlugin}
<h2 class="text-2xl font-bold mb-4">{$selectedPlugin.name}</h2>
<DynamicForm schema={$selectedPlugin.schema} on:submit={handleFormSubmit} />
<button on:click={() => selectedPlugin.set(null)} class="mt-4 bg-gray-500 text-white p-2 rounded">
Back to Dashboard
</button>
{:else}
<Dashboard />
{/if}
</div>
</main>
<!-- [/SECTION] -->
<!-- [/DEF:App:Component] -->

View File

@@ -12,6 +12,9 @@
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import type { DashboardMetadata } from '../types/dashboard'; import type { DashboardMetadata } from '../types/dashboard';
import { t } from '../lib/i18n';
import { Button, Input } from '../lib/ui';
import GitManager from './git/GitManager.svelte';
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
@@ -27,6 +30,12 @@
let sortDirection: "asc" | "desc" = "asc"; let sortDirection: "asc" | "desc" = "asc";
// [/SECTION] // [/SECTION]
// [SECTION: UI STATE]
let showGitManager = false;
let gitDashboardId: number | null = null;
let gitDashboardTitle = "";
// [/SECTION]
// [SECTION: DERIVED] // [SECTION: DERIVED]
$: filteredDashboards = dashboards.filter(d => $: filteredDashboards = dashboards.filter(d =>
d.title.toLowerCase().includes(filterText.toLowerCase()) d.title.toLowerCase().includes(filterText.toLowerCase())
@@ -120,61 +129,83 @@
} }
// [/DEF:goToPage:Function] // [/DEF:goToPage:Function]
// [DEF:openGit:Function]
/**
* @purpose Opens the Git management modal for a dashboard.
*/
function openGit(dashboard: DashboardMetadata) {
gitDashboardId = dashboard.id;
gitDashboardTitle = dashboard.title;
showGitManager = true;
}
// [/DEF:openGit:Function]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="dashboard-grid"> <div class="dashboard-grid">
<!-- Filter Input --> <!-- Filter Input -->
<div class="mb-4"> <div class="mb-6">
<input <Input
type="text"
bind:value={filterText} bind:value={filterText}
placeholder="Search dashboards..." placeholder={$t.dashboard.search}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
<!-- Grid/Table --> <!-- Grid/Table -->
<div class="overflow-x-auto"> <div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full bg-white border border-gray-300"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-4 py-2 border-b"> <th class="px-6 py-3 text-left">
<input <input
type="checkbox" type="checkbox"
checked={allSelected} checked={allSelected}
indeterminate={someSelected && !allSelected} indeterminate={someSelected && !allSelected}
on:change={(e) => handleSelectAll((e.target as HTMLInputElement).checked)} 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>
<th class="px-4 py-2 border-b cursor-pointer" on:click={() => handleSort('title')}> <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" on:click={() => handleSort('title')}>
Title {sortColumn === 'title' ? (sortDirection === 'asc' ? '↑' : '↓') : ''} {$t.dashboard.title} {sortColumn === 'title' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
</th> </th>
<th class="px-4 py-2 border-b cursor-pointer" on:click={() => handleSort('last_modified')}> <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" on:click={() => handleSort('last_modified')}>
Last Modified {sortColumn === 'last_modified' ? (sortDirection === 'asc' ? '↑' : '↓') : ''} {$t.dashboard.last_modified} {sortColumn === 'last_modified' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
</th> </th>
<th class="px-4 py-2 border-b cursor-pointer" on:click={() => handleSort('status')}> <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" on:click={() => handleSort('status')}>
Status {sortColumn === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''} {$t.dashboard.status} {sortColumn === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
</th> </th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.dashboard.git}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="bg-white divide-y divide-gray-200">
{#each paginatedDashboards as dashboard (dashboard.id)} {#each paginatedDashboards as dashboard (dashboard.id)}
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-2 border-b"> <td class="px-6 py-4 whitespace-nowrap">
<input <input
type="checkbox" type="checkbox"
checked={selectedIds.includes(dashboard.id)} checked={selectedIds.includes(dashboard.id)}
on:change={(e) => handleSelectionChange(dashboard.id, (e.target as HTMLInputElement).checked)} on:change={(e) => handleSelectionChange(dashboard.id, (e.target as HTMLInputElement).checked)}
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/> />
</td> </td>
<td class="px-4 py-2 border-b">{dashboard.title}</td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{dashboard.title}</td>
<td class="px-4 py-2 border-b">{new Date(dashboard.last_modified).toLocaleDateString()}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{new Date(dashboard.last_modified).toLocaleDateString()}</td>
<td class="px-4 py-2 border-b"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span class="px-2 py-1 text-xs font-medium rounded-full {dashboard.status === 'published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}"> <span class="px-2 py-1 text-xs font-medium rounded-full {dashboard.status === 'published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
{dashboard.status} {dashboard.status}
</span> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<Button
variant="ghost"
size="sm"
on:click={() => openGit(dashboard)}
class="text-blue-600 hover:text-blue-900"
>
{$t.git.manage}
</Button>
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -182,28 +213,42 @@
</div> </div>
<!-- Pagination Controls --> <!-- Pagination Controls -->
<div class="flex items-center justify-between mt-4"> <div class="flex items-center justify-between mt-6">
<div class="text-sm text-gray-700"> <div class="text-sm text-gray-500">
Showing {currentPage * pageSize + 1} to {Math.min((currentPage + 1) * pageSize, sortedDashboards.length)} of {sortedDashboards.length} dashboards {($t.dashboard?.showing || "")
.replace('{start}', (currentPage * pageSize + 1).toString())
.replace('{end}', Math.min((currentPage + 1) * pageSize, sortedDashboards.length).toString())
.replace('{total}', sortedDashboards.length.toString())}
</div> </div>
<div class="flex space-x-2"> <div class="flex gap-2">
<button <Button
class="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" variant="secondary"
size="sm"
disabled={currentPage === 0} disabled={currentPage === 0}
on:click={() => goToPage(currentPage - 1)} on:click={() => goToPage(currentPage - 1)}
> >
Previous {$t.dashboard.previous}
</button> </Button>
<button <Button
class="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" variant="secondary"
size="sm"
disabled={currentPage >= totalPages - 1} disabled={currentPage >= totalPages - 1}
on:click={() => goToPage(currentPage + 1)} on:click={() => goToPage(currentPage + 1)}
> >
Next {$t.dashboard.next}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
{#if showGitManager && gitDashboardId}
<GitManager
dashboardId={gitDashboardId}
dashboardTitle={gitDashboardTitle}
bind:show={showGitManager}
/>
{/if}
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style> <style>

View File

@@ -57,4 +57,4 @@
/* Component specific styles */ /* Component specific styles */
</style> </style>
<!-- [/DEF:EnvSelector:Component] --> <!-- [/DEF:EnvSelector:Component] -->

View File

@@ -7,53 +7,41 @@
--> -->
<script> <script>
import { page } from '$app/stores'; import { page } from '$app/stores';
import { t } from '$lib/i18n';
import { LanguageSwitcher } from '$lib/ui';
</script> </script>
<header class="bg-white shadow-md p-4 flex justify-between items-center"> <header class="bg-white shadow-md p-4 flex justify-between items-center">
<a <a
href="/" href="/"
class="text-3xl font-bold text-gray-800 focus:outline-none" class="text-2xl font-bold text-gray-800 focus:outline-none"
> >
Superset Tools Superset Tools
</a> </a>
<nav class="space-x-4"> <nav class="flex items-center space-x-4">
<a <a
href="/" href="/"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname === '/' ? 'text-blue-600 border-b-2 border-blue-600' : ''}" class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname === '/' ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
> >
Dashboard {$t.nav.dashboard}
</a>
<a
href="/migration"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/migration') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
Migration
</a> </a>
<a <a
href="/tasks" 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' : ''}" class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/tasks') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
> >
Tasks {$t.nav.tasks}
</a> </a>
<div class="relative inline-block group">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/tools') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
Tools
</button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100">
<a href="/tools/search" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Dataset Search</a>
<a href="/tools/mapper" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Dataset Mapper</a>
<a href="/tools/debug" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">System Debug</a>
</div>
</div>
<div class="relative inline-block group"> <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' : ''}"> <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' : ''}">
Settings {$t.nav.settings}
</button> </button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100"> <div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
<a href="/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">General Settings</a> <a href="/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_general}</a>
<a href="/settings/connections" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Connections</a> <a href="/settings/connections" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_connections}</a>
<a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_git}</a>
</div> </div>
</div> </div>
<LanguageSwitcher />
</nav> </nav>
</header> </header>
<!-- [/DEF:Navbar:Component] --> <!-- [/DEF:Navbar:Component] -->

View File

@@ -9,6 +9,7 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { t } from '../lib/i18n';
export let tasks: Array<any> = []; export let tasks: Array<any> = [];
export let loading: boolean = false; export let loading: boolean = false;
@@ -58,9 +59,9 @@
<div class="bg-white shadow overflow-hidden sm:rounded-md"> <div class="bg-white shadow overflow-hidden sm:rounded-md">
{#if loading && tasks.length === 0} {#if loading && tasks.length === 0}
<div class="p-4 text-center text-gray-500">Loading tasks...</div> <div class="p-4 text-center text-gray-500">{$t.tasks?.loading || 'Loading...'}</div>
{:else if tasks.length === 0} {:else if tasks.length === 0}
<div class="p-4 text-center text-gray-500">No tasks found.</div> <div class="p-4 text-center text-gray-500">{$t.tasks?.no_tasks || 'No tasks found.'}</div>
{:else} {:else}
<ul class="divide-y divide-gray-200"> <ul class="divide-y divide-gray-200">
{#each tasks as task (task.id)} {#each tasks as task (task.id)}
@@ -94,7 +95,7 @@
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
</svg> </svg>
<p> <p>
Started {formatTime(task.started_at)} {($t.tasks?.started || "").replace('{time}', formatTime(task.started_at))}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,15 +1,18 @@
<!-- [DEF:TaskLogViewer:Component] --> <!-- [DEF:TaskLogViewer:Component] -->
<!-- <!--
@SEMANTICS: task, log, viewer, modal @SEMANTICS: task, log, viewer, modal, inline
@PURPOSE: Displays detailed logs for a specific task in a modal. @PURPOSE: Displays detailed logs for a specific task in a modal or inline.
@LAYER: UI @LAYER: UI
@RELATION: USES -> frontend/src/lib/api.js (inferred) @RELATION: USES -> frontend/src/services/taskService.js
--> -->
<script> <script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte'; import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { getTaskLogs } from '../services/taskService.js'; import { getTaskLogs } from '../services/taskService.js';
import { t } from '../lib/i18n';
import { Button } from '../lib/ui';
export let show = false; export let show = false;
export let inline = false;
export let taskId = null; export let taskId = null;
export let taskStatus = null; // To know if we should poll export let taskStatus = null; // To know if we should poll
@@ -22,19 +25,27 @@
let autoScroll = true; let autoScroll = true;
let logContainer; let logContainer;
$: shouldShow = inline || show;
// [DEF:fetchLogs:Function] // [DEF:fetchLogs:Function]
// @PURPOSE: Fetches logs for the current task. /**
// @PRE: taskId must be set. * @purpose Fetches logs for the current task.
// @POST: logs array is updated with data from taskService. * @pre taskId must be set.
* @post logs array is updated with data from taskService.
* @side_effect Updates logs, loading, and error state.
*/
async function fetchLogs() { async function fetchLogs() {
if (!taskId) return; if (!taskId) return;
console.log(`[fetchLogs][Action] Fetching logs for task context={{'taskId': '${taskId}'}}`);
try { try {
logs = await getTaskLogs(taskId); logs = await getTaskLogs(taskId);
if (autoScroll) { if (autoScroll) {
scrollToBottom(); scrollToBottom();
} }
console.log(`[fetchLogs][Coherence:OK] Logs fetched context={{'count': ${logs.length}}}`);
} catch (e) { } catch (e) {
error = e.message; error = e.message;
console.error(`[fetchLogs][Coherence:Failed] Error fetching logs context={{'error': '${e.message}'}}`);
} finally { } finally {
loading = false; loading = false;
} }
@@ -42,9 +53,11 @@
// [/DEF:fetchLogs:Function] // [/DEF:fetchLogs:Function]
// [DEF:scrollToBottom:Function] // [DEF:scrollToBottom:Function]
// @PURPOSE: Scrolls the log container to the bottom. /**
// @PRE: logContainer element must be bound. * @purpose Scrolls the log container to the bottom.
// @POST: logContainer scrollTop is set to scrollHeight. * @pre logContainer element must be bound.
* @post logContainer scrollTop is set to scrollHeight.
*/
function scrollToBottom() { function scrollToBottom() {
if (logContainer) { if (logContainer) {
setTimeout(() => { setTimeout(() => {
@@ -55,9 +68,11 @@
// [/DEF:scrollToBottom:Function] // [/DEF:scrollToBottom:Function]
// [DEF:handleScroll:Function] // [DEF:handleScroll:Function]
// @PURPOSE: Updates auto-scroll preference based on scroll position. /**
// @PRE: logContainer scroll event fired. * @purpose Updates auto-scroll preference based on scroll position.
// @POST: autoScroll boolean is updated. * @pre logContainer scroll event fired.
* @post autoScroll boolean is updated.
*/
function handleScroll() { function handleScroll() {
if (!logContainer) return; if (!logContainer) return;
// If user scrolls up, disable auto-scroll // If user scrolls up, disable auto-scroll
@@ -68,9 +83,11 @@
// [/DEF:handleScroll:Function] // [/DEF:handleScroll:Function]
// [DEF:close:Function] // [DEF:close:Function]
// @PURPOSE: Closes the log viewer modal. /**
// @PRE: Modal is open. * @purpose Closes the log viewer modal.
// @POST: Modal is closed and close event is dispatched. * @pre Modal is open.
* @post Modal is closed and close event is dispatched.
*/
function close() { function close() {
dispatch('close'); dispatch('close');
show = false; show = false;
@@ -78,9 +95,11 @@
// [/DEF:close:Function] // [/DEF:close:Function]
// [DEF:getLogLevelColor:Function] // [DEF:getLogLevelColor:Function]
// @PURPOSE: Returns the CSS color class for a given log level. /**
// @PRE: level string is provided. * @purpose Returns the CSS color class for a given log level.
// @POST: Returns tailwind color class string. * @pre level string is provided.
* @post Returns tailwind color class string.
*/
function getLogLevelColor(level) { function getLogLevelColor(level) {
switch (level) { switch (level) {
case 'INFO': return 'text-blue-600'; case 'INFO': return 'text-blue-600';
@@ -92,8 +111,10 @@
} }
// [/DEF:getLogLevelColor:Function] // [/DEF:getLogLevelColor:Function]
// React to changes in show/taskId // React to changes in show/taskId/taskStatus
$: if (show && taskId) { $: if (shouldShow && taskId) {
if (interval) clearInterval(interval);
logs = []; logs = [];
loading = true; loading = true;
error = ""; error = "";
@@ -108,76 +129,116 @@
} }
// [DEF:onDestroy:Function] // [DEF:onDestroy:Function]
// @PURPOSE: Cleans up the polling interval. /**
// @PRE: Component is being destroyed. * @purpose Cleans up the polling interval.
// @POST: Polling interval is cleared. * @pre Component is being destroyed.
* @post Polling interval is cleared.
*/
onDestroy(() => { onDestroy(() => {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
}); });
// [/DEF:onDestroy:Function] // [/DEF:onDestroy:Function]
</script> </script>
{#if show} {#if shouldShow}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> {#if inline}
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div class="flex flex-col h-full w-full p-4">
<!-- Background overlay --> <div class="flex justify-between items-center mb-4">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" on:click={close}></div> <h3 class="text-lg font-medium text-gray-900">
{$t.tasks?.logs_title} <span class="text-sm text-gray-500 font-normal">({taskId})</span>
</h3>
<Button variant="ghost" size="sm" on:click={fetchLogs} class="text-blue-600">{$t.tasks?.refresh}</Button>
</div>
<div class="flex-1 border rounded-md bg-gray-50 p-4 overflow-y-auto font-mono text-sm"
bind:this={logContainer}
on:scroll={handleScroll}>
{#if loading && logs.length === 0}
<p class="text-gray-500 text-center">{$t.tasks?.loading}</p>
{:else if error}
<p class="text-red-500 text-center">{error}</p>
{:else if logs.length === 0}
<p class="text-gray-500 text-center">{$t.tasks?.no_logs}</p>
{:else}
{#each logs as log}
<div class="mb-1 hover:bg-gray-100 p-1 rounded">
<span class="text-gray-400 text-xs mr-2">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}">
[{log.level}]
</span>
<span class="text-gray-800 break-words">
{log.message}
</span>
{#if log.context}
<div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto">
<pre>{JSON.stringify(log.context, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
{:else}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" on:click={close}></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full"> <div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full"> <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center" id="modal-title"> <h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center" id="modal-title">
<span>Task Logs <span class="text-sm text-gray-500 font-normal">({taskId})</span></span> <span>{$t.tasks.logs_title} <span class="text-sm text-gray-500 font-normal">({taskId})</span></span>
<button on:click={fetchLogs} class="text-sm text-indigo-600 hover:text-indigo-900">Refresh</button> <Button variant="ghost" size="sm" on:click={fetchLogs} class="text-blue-600">{$t.tasks.refresh}</Button>
</h3> </h3>
<div class="mt-4 border rounded-md bg-gray-50 p-4 h-96 overflow-y-auto font-mono text-sm" <div class="mt-4 border rounded-md bg-gray-50 p-4 h-96 overflow-y-auto font-mono text-sm"
bind:this={logContainer} bind:this={logContainer}
on:scroll={handleScroll}> on:scroll={handleScroll}>
{#if loading && logs.length === 0} {#if loading && logs.length === 0}
<p class="text-gray-500 text-center">Loading logs...</p> <p class="text-gray-500 text-center">{$t.tasks.loading}</p>
{:else if error} {:else if error}
<p class="text-red-500 text-center">{error}</p> <p class="text-red-500 text-center">{error}</p>
{:else if logs.length === 0} {:else if logs.length === 0}
<p class="text-gray-500 text-center">No logs available.</p> <p class="text-gray-500 text-center">{$t.tasks.no_logs}</p>
{:else} {:else}
{#each logs as log} {#each logs as log}
<div class="mb-1 hover:bg-gray-100 p-1 rounded"> <div class="mb-1 hover:bg-gray-100 p-1 rounded">
<span class="text-gray-400 text-xs mr-2"> <span class="text-gray-400 text-xs mr-2">
{new Date(log.timestamp).toLocaleTimeString()} {new Date(log.timestamp).toLocaleTimeString()}
</span> </span>
<span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}"> <span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}">
[{log.level}] [{log.level}]
</span> </span>
<span class="text-gray-800 break-words"> <span class="text-gray-800 break-words">
{log.message} {log.message}
</span> </span>
{#if log.context} {#if log.context}
<div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto"> <div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto">
<pre>{JSON.stringify(log.context, null, 2)}</pre> <pre>{JSON.stringify(log.context, null, 2)}</pre>
</div> </div>
{/if} {/if}
</div> </div>
{/each} {/each}
{/if} {/if}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> <Button variant="secondary" on:click={close}>
<button {$t.common.cancel}
type="button" </Button>
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:ml-3 sm:w-auto sm:text-sm" </div>
on:click={close}
>
Close
</button>
</div> </div>
</div> </div>
</div> </div>
</div> {/if}
{/if} {/if}
<!-- [/DEF:TaskLogViewer:Component] --> <!-- [/DEF:TaskLogViewer:Component] -->

View File

@@ -0,0 +1,84 @@
<!-- [DEF:BackupList:Component] -->
<!--
@SEMANTICS: backup, list, table
@PURPOSE: Displays a list of existing backups.
@LAYER: Component
@RELATION: USED_BY -> frontend/src/components/backups/BackupManager.svelte
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { t } from '../../lib/i18n';
import { Button } from '../../lib/ui';
import type { Backup } from '../../types/backup';
import { goto } from '$app/navigation';
// [/SECTION]
// [SECTION: PROPS]
/**
* @type {Backup[]}
* @description Array of backup objects to display.
*/
export let backups: Backup[] = [];
// [/SECTION]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.storage.table.name}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.tasks.target_env}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.storage.table.created_at}
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.storage.table.actions}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each backups as backup}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{backup.name}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{backup.environment}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(backup.created_at).toLocaleString()}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Button
variant="ghost"
size="sm"
class="text-blue-600 hover:text-blue-900"
on:click={() => goto(`/tools/storage?path=backups/${backup.name}`)}
>
{$t.storage.table.go_to_storage}
</Button>
</td>
</tr>
{:else}
<tr>
<td colspan="4" class="px-6 py-10 text-center text-gray-500">
{$t.storage.no_files}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- [/SECTION] -->
<style>
</style>
<!-- [/DEF:BackupList:Component] -->

View File

@@ -0,0 +1,241 @@
<!-- [DEF:BackupManager:Component] -->
<!--
@SEMANTICS: backup, manager, orchestrator
@PURPOSE: Main container for backup management, handling creation and listing.
@LAYER: Feature
@RELATION: USES -> BackupList
@RELATION: USES -> api
@INVARIANT: Only one backup task can be triggered at a time from the UI.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '../../lib/i18n';
import { api, requestApi } from '../../lib/api';
import { addToast } from '../../lib/toasts';
import { Button, Card, Select, Input } from '../../lib/ui';
import BackupList from './BackupList.svelte';
import type { Backup } from '../../types/backup';
// [/SECTION]
// [SECTION: STATE]
let backups: Backup[] = [];
let environments: any[] = [];
let selectedEnvId = '';
let loading = true;
let creating = false;
let savingSchedule = false;
// Schedule state for selected environment
let scheduleEnabled = false;
let cronExpression = '0 0 * * *';
$: selectedEnv = environments.find(e => e.id === selectedEnvId);
$: if (selectedEnv) {
scheduleEnabled = selectedEnv.backup_schedule?.enabled ?? false;
cronExpression = selectedEnv.backup_schedule?.cron_expression ?? '0 0 * * *';
}
// [/SECTION]
// [DEF:loadData:Function]
/**
* @purpose Loads backups and environments from the backend.
*
* @pre API must be reachable.
* @post environments and backups stores are populated.
*
* @returns {Promise<void>}
* @side_effect Updates local state variables.
*/
// @RELATION: CALLS -> api.getEnvironmentsList
// @RELATION: CALLS -> api.requestApi
async function loadData() {
console.log("[BackupManager][Entry] Loading data.");
loading = true;
try {
const [envsData, storageData] = await Promise.all([
api.getEnvironmentsList(),
requestApi('/storage/files?category=backups')
]);
environments = envsData;
// Map storage files to Backup type
backups = (storageData || []).map((file: any) => ({
id: file.name,
name: file.name,
environment: file.path.split('/')[0] || 'Unknown',
created_at: file.created_at,
size_bytes: file.size,
status: 'success'
}));
console.log("[BackupManager][Action] Data loaded successfully.");
} catch (error) {
console.error("[BackupManager][Coherence:Failed] Load failed", error);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:handleCreateBackup:Function]
/**
* @purpose Triggers a new backup task for the selected environment.
*
* @pre selectedEnvId must be a valid environment ID.
* @post A new task is created on the backend.
*
* @returns {Promise<void>}
* @side_effect Dispatches a toast notification.
*/
// @RELATION: CALLS -> api.createTask
// [DEF:handleUpdateSchedule:Function]
/**
* @purpose Updates the backup schedule for the selected environment.
* @pre selectedEnvId must be set.
* @post Environment config is updated on the backend.
*/
async function handleUpdateSchedule() {
if (!selectedEnvId) return;
console.log(`[BackupManager][Action] Updating schedule for env: ${selectedEnvId}`);
savingSchedule = true;
try {
await api.updateEnvironmentSchedule(selectedEnvId, {
enabled: scheduleEnabled,
cron_expression: cronExpression
});
addToast($t.common.success, 'success');
// Update local state
environments = environments.map(e =>
e.id === selectedEnvId
? { ...e, backup_schedule: { enabled: scheduleEnabled, cron_expression: cronExpression } }
: e
);
} catch (error) {
console.error("[BackupManager][Coherence:Failed] Schedule update failed", error);
} finally {
savingSchedule = false;
}
}
// [/DEF:handleUpdateSchedule:Function]
async function handleCreateBackup() {
if (!selectedEnvId) {
addToast($t.tasks.select_env, 'error');
return;
}
console.log(`[BackupManager][Action] Triggering backup for env: ${selectedEnvId}`);
creating = true;
try {
await api.createTask('superset-backup', { environment_id: selectedEnvId });
addToast($t.common.success, 'success');
console.log("[BackupManager][Coherence:OK] Backup task triggered.");
} catch (error) {
console.error("[BackupManager][Coherence:Failed] Create failed", error);
} finally {
creating = false;
}
}
// [/DEF:handleCreateBackup:Function]
onMount(loadData);
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="space-y-6">
<Card title={$t.tasks.manual_backup}>
<div class="space-y-4">
<div class="flex items-end gap-4">
<div class="flex-1">
<Select
label={$t.tasks.target_env}
bind:value={selectedEnvId}
options={[
{ value: '', label: $t.tasks.select_env },
...environments.map(e => ({ value: e.id, label: e.name }))
]}
/>
</div>
<Button
variant="primary"
on:click={handleCreateBackup}
disabled={creating || !selectedEnvId}
>
{creating ? $t.common.loading : $t.tasks.start_backup}
</Button>
</div>
{#if selectedEnvId}
<div class="pt-6 border-t border-gray-100 mt-4">
<h3 class="text-sm font-semibold text-gray-800 mb-4 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{$t.tasks.backup_schedule}
</h3>
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div class="flex flex-col md:flex-row md:items-start gap-6">
<div class="pt-8">
<label class="flex items-center gap-3 cursor-pointer group">
<div class="relative inline-flex items-center">
<input type="checkbox" bind:checked={scheduleEnabled} class="sr-only peer" />
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-gray-900 transition-colors">{$t.tasks.schedule_enabled}</span>
</div>
</label>
</div>
<div class="flex-1 space-y-2">
<Input
label={$t.tasks.cron_label}
placeholder="0 0 * * *"
bind:value={cronExpression}
disabled={!scheduleEnabled}
/>
<p class="text-xs text-gray-500 italic">{$t.tasks.cron_hint}</p>
</div>
<div class="pt-8">
<Button
variant="secondary"
on:click={handleUpdateSchedule}
disabled={savingSchedule}
class="min-w-[100px]"
>
{#if savingSchedule}
<span class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{$t.common.loading}
</span>
{:else}
{$t.common.save}
{/if}
</Button>
</div>
</div>
</div>
</div>
{/if}
</div>
</Card>
<div class="space-y-3">
<h2 class="text-lg font-semibold text-gray-700">{$t.storage.backups}</h2>
{#if loading}
<div class="py-10 text-center text-gray-500">{$t.common.loading}</div>
{:else}
<BackupList {backups} />
{/if}
</div>
</div>
<!-- [/SECTION] -->
<!-- [/DEF:BackupManager:Component] -->

View File

@@ -0,0 +1,178 @@
<!-- [DEF:BranchSelector:Component] -->
<!--
@SEMANTICS: git, branch, selection, checkout
@PURPOSE: UI для выбора и создания веток Git.
@LAYER: Component
@RELATION: CALLS -> gitService.getBranches
@RELATION: CALLS -> gitService.checkoutBranch
@RELATION: CALLS -> gitService.createBranch
@RELATION: DISPATCHES -> change
-->
<script>
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n';
import { Button, Select, Input } from '../../lib/ui';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let currentBranch = 'main';
// [/SECTION]
// [SECTION: STATE]
let branches = [];
let loading = false;
let showCreate = false;
let newBranchName = '';
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:onMount:Function]
/**
* @purpose Load branches when component is mounted.
* @pre Component is initialized.
* @post loadBranches is called.
*/
onMount(async () => {
await loadBranches();
});
// [/DEF:onMount:Function]
// [DEF:loadBranches:Function]
/**
* @purpose Загружает список веток для дашборда.
* @pre dashboardId is provided.
* @post branches обновлен.
*/
async function loadBranches() {
console.log(`[BranchSelector][Action] Loading branches for dashboard ${dashboardId}`);
loading = true;
try {
branches = await gitService.getBranches(dashboardId);
console.log(`[BranchSelector][Coherence:OK] Loaded ${branches.length} branches`);
} catch (e) {
console.error(`[BranchSelector][Coherence:Failed] ${e.message}`);
toast('Failed to load branches', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadBranches:Function]
// [DEF:handleSelect:Function]
/**
* @purpose Handles branch selection from dropdown.
* @pre event contains branch name.
* @post handleCheckout is called with selected branch.
*/
function handleSelect(event) {
handleCheckout(event.target.value);
}
// [/DEF:handleSelect:Function]
// [DEF:handleCheckout:Function]
/**
* @purpose Переключает текущую ветку.
* @param {string} branchName - Имя ветки.
* @post currentBranch обновлен, событие отправлено.
*/
async function handleCheckout(branchName) {
console.log(`[BranchSelector][Action] Checking out branch ${branchName}`);
try {
await gitService.checkoutBranch(dashboardId, branchName);
currentBranch = branchName;
dispatch('change', { branch: branchName });
toast(`Switched to ${branchName}`, 'success');
console.log(`[BranchSelector][Coherence:OK] Checked out ${branchName}`);
} catch (e) {
console.error(`[BranchSelector][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
}
}
// [/DEF:handleCheckout:Function]
// [DEF:handleCreate:Function]
/**
* @purpose Создает новую ветку.
* @pre newBranchName is not empty.
* @post Новая ветка создана и загружена; showCreate reset.
*/
async function handleCreate() {
if (!newBranchName) return;
console.log(`[BranchSelector][Action] Creating branch ${newBranchName} from ${currentBranch}`);
try {
await gitService.createBranch(dashboardId, newBranchName, currentBranch);
toast(`Created branch ${newBranchName}`, 'success');
showCreate = false;
newBranchName = '';
await loadBranches();
console.log(`[BranchSelector][Coherence:OK] Branch created`);
} catch (e) {
console.error(`[BranchSelector][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
}
}
// [/DEF:handleCreate:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="flex-grow">
<Select
bind:value={currentBranch}
on:change={handleSelect}
disabled={loading}
options={branches.map(b => ({ value: b.name, label: b.name }))}
/>
</div>
<Button
variant="ghost"
size="sm"
on:click={() => showCreate = !showCreate}
disabled={loading}
class="text-blue-600"
>
+ {$t.git.new_branch}
</Button>
</div>
{#if showCreate}
<div class="flex items-end gap-2 bg-gray-50 p-3 rounded-lg border border-dashed border-gray-200">
<div class="flex-grow">
<Input
bind:value={newBranchName}
placeholder="branch-name"
disabled={loading}
/>
</div>
<Button
variant="primary"
size="sm"
on:click={handleCreate}
disabled={loading || !newBranchName}
isLoading={loading}
class="bg-green-600 hover:bg-green-700"
>
{$t.git.create}
</Button>
<Button
variant="ghost"
size="sm"
on:click={() => showCreate = false}
disabled={loading}
>
{$t.common.cancel}
</Button>
</div>
{/if}
</div>
<!-- [/SECTION] -->
<!-- [/DEF:BranchSelector:Component] -->

View File

@@ -0,0 +1,95 @@
<!-- [DEF:CommitHistory:Component] -->
<!--
@SEMANTICS: git, history, commits, audit
@PURPOSE: Displays the commit history for a specific dashboard.
@LAYER: Component
@RELATION: CALLS -> gitService.getHistory
-->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n';
import { Button } from '../../lib/ui';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
// [/SECTION]
// [SECTION: STATE]
let history = [];
let loading = false;
// [/SECTION]
// [DEF:onMount:Function]
/**
* @purpose Load history when component is mounted.
* @pre Component is initialized with dashboardId.
* @post loadHistory is called.
*/
onMount(async () => {
await loadHistory();
});
// [/DEF:onMount:Function]
// [DEF:loadHistory:Function]
/**
* @purpose Fetch commit history from the backend.
* @pre dashboardId is valid.
* @post history state is updated.
*/
async function loadHistory() {
console.log(`[CommitHistory][Action] Loading history for dashboard ${dashboardId}`);
loading = true;
try {
history = await gitService.getHistory(dashboardId);
console.log(`[CommitHistory][Coherence:OK] Loaded ${history.length} commits`);
} catch (e) {
console.error(`[CommitHistory][Coherence:Failed] ${e.message}`);
toast('Failed to load commit history', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadHistory:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="mt-2">
<div class="flex justify-between items-center mb-6">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">
{$t.git.history}
</h3>
<Button variant="ghost" size="sm" on:click={loadHistory} class="text-blue-600">
{$t.git.refresh}
</Button>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
{:else if history.length === 0}
<p class="text-gray-500 italic text-center py-12">{$t.git.no_commits}</p>
{:else}
<div class="space-y-3 max-h-96 overflow-y-auto pr-2">
{#each history as commit}
<div class="border-l-2 border-blue-500 pl-4 py-1">
<div class="flex justify-between items-start">
<span class="font-medium text-sm">{commit.message}</span>
<span class="text-xs text-gray-400 font-mono">{commit.hash.substring(0, 7)}</span>
</div>
<div class="text-xs text-gray-500 mt-1">
{commit.author}{new Date(commit.timestamp).toLocaleString()}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- [/SECTION] -->
<!-- [/DEF:CommitHistory:Component] -->

View File

@@ -0,0 +1,175 @@
<!-- [DEF:CommitModal:Component] -->
<!--
@SEMANTICS: git, commit, modal, version_control, diff
@PURPOSE: Модальное окно для создания коммита с просмотром изменений (diff).
@LAYER: Component
@RELATION: CALLS -> gitService.commit
@RELATION: CALLS -> gitService.getStatus
@RELATION: CALLS -> gitService.getDiff
@RELATION: DISPATCHES -> commit
-->
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher, onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let show = false;
// [/SECTION]
// [SECTION: STATE]
let message = '';
let committing = false;
let status = null;
let diff = '';
let loading = false;
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:loadStatus:Function]
/**
* @purpose Загружает текущий статус репозитория и diff.
* @pre dashboardId должен быть валидным.
*/
async function loadStatus() {
if (!dashboardId || !show) return;
loading = true;
try {
console.log(`[CommitModal][Action] Loading status and diff for ${dashboardId}`);
status = await gitService.getStatus(dashboardId);
// Fetch both unstaged and staged diffs to show complete picture
const unstagedDiff = await gitService.getDiff(dashboardId, null, false);
const stagedDiff = await gitService.getDiff(dashboardId, null, true);
diff = "";
if (stagedDiff) diff += "--- STAGED CHANGES ---\n" + stagedDiff + "\n\n";
if (unstagedDiff) diff += "--- UNSTAGED CHANGES ---\n" + unstagedDiff;
if (!diff) diff = "";
} catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast('Failed to load changes', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadStatus:Function]
// [DEF:handleCommit:Function]
/**
* @purpose Создает коммит с указанным сообщением.
* @pre message не должно быть пустым.
* @post Коммит создан, событие отправлено, модальное окно закрыто.
*/
async function handleCommit() {
if (!message) return;
console.log(`[CommitModal][Action] Committing changes for dashboard ${dashboardId}`);
committing = true;
try {
await gitService.commit(dashboardId, message, []);
toast('Changes committed successfully', 'success');
dispatch('commit');
show = false;
message = '';
console.log(`[CommitModal][Coherence:OK] Committed`);
} catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
} finally {
committing = false;
}
}
// [/DEF:handleCommit:Function]
$: if (show) loadStatus();
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<h2 class="text-xl font-bold mb-4">Commit Changes</h2>
<div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden">
<!-- Left: Message and Files -->
<div class="w-full md:w-1/3 flex flex-col">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Commit Message</label>
<textarea
bind:value={message}
class="w-full border rounded p-2 h-32 focus:ring-2 focus:ring-blue-500 outline-none resize-none"
placeholder="Describe your changes..."
></textarea>
</div>
{#if status}
<div class="flex-1 overflow-y-auto">
<h3 class="text-sm font-bold text-gray-500 uppercase mb-2">Changed Files</h3>
<ul class="text-xs space-y-1">
{#each status.staged_files as file}
<li class="text-green-600 flex items-center font-semibold" title="Staged">
<span class="mr-2">S</span> {file}
</li>
{/each}
{#each status.modified_files as file}
<li class="text-yellow-600 flex items-center" title="Modified (Unstaged)">
<span class="mr-2">M</span> {file}
</li>
{/each}
{#each status.untracked_files as file}
<li class="text-blue-600 flex items-center" title="Untracked">
<span class="mr-2">?</span> {file}
</li>
{/each}
</ul>
</div>
{/if}
</div>
<!-- Right: Diff Viewer -->
<div class="w-full md:w-2/3 flex flex-col overflow-hidden border rounded bg-gray-50">
<div class="bg-gray-200 px-3 py-1 text-xs font-bold text-gray-600 border-b">Changes Preview</div>
<div class="flex-1 overflow-auto p-2">
{#if loading}
<div class="flex items-center justify-center h-full text-gray-500">Loading diff...</div>
{:else if diff}
<pre class="text-xs font-mono whitespace-pre-wrap">{diff}</pre>
{:else}
<div class="flex items-center justify-center h-full text-gray-500 italic">No changes detected</div>
{/if}
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6 pt-4 border-t">
<button
on:click={() => show = false}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
on:click={handleCommit}
disabled={committing || !message || loading || (!status?.is_dirty && status?.staged_files?.length === 0)}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{committing ? 'Committing...' : 'Commit'}
</button>
</div>
</div>
</div>
{/if}
<!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:CommitModal:Component] -->

View File

@@ -0,0 +1,142 @@
<!-- [DEF:ConflictResolver:Component] -->
<!--
@SEMANTICS: git, conflict, resolution, merge
@PURPOSE: UI for resolving merge conflicts (Keep Mine / Keep Theirs).
@LAYER: Component
@RELATION: DISPATCHES -> resolve
@INVARIANT: User must resolve all conflicts before saving.
-->
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
/** @type {Array<{file_path: string, mine: string, theirs: string}>} */
export let conflicts = [];
export let show = false;
// [/SECTION]
// [SECTION: STATE]
const dispatch = createEventDispatcher();
/** @type {Object.<string, 'mine' | 'theirs' | 'manual'>} */
let resolutions = {};
// [/SECTION]
// [DEF:resolve:Function]
/**
* @purpose Set resolution strategy for a file.
* @pre file path must exist in conflicts array.
* @post resolutions state is updated for the given file.
* @param {string} file - File path.
* @param {'mine'|'theirs'} strategy - Resolution strategy.
* @side_effect Updates resolutions state.
*/
function resolve(file, strategy) {
console.log(`[ConflictResolver][Action] Resolving ${file} with ${strategy}`);
resolutions[file] = strategy;
resolutions = { ...resolutions }; // Trigger update
}
// [/DEF:resolve:Function]
// [DEF:handleSave:Function]
/**
* @purpose Validate and submit resolutions.
* @pre All conflicts must have a resolution.
* @post 'resolve' event dispatched if valid.
* @side_effect Dispatches event and closes modal.
*/
function handleSave() {
// 1. Guard Clause (@PRE)
const unresolved = conflicts.filter(c => !resolutions[c.file_path]);
if (unresolved.length > 0) {
console.warn(`[ConflictResolver][Coherence:Failed] ${unresolved.length} unresolved conflicts`);
toast(`Please resolve all conflicts first. (${unresolved.length} remaining)`, 'error');
return;
}
// 2. Implementation
console.log(`[ConflictResolver][Coherence:OK] All conflicts resolved`);
dispatch('resolve', resolutions);
show = false;
}
// [/DEF:handleSave:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
<h2 class="text-xl font-bold mb-4 text-red-600">Merge Conflicts Detected</h2>
<p class="text-gray-600 mb-4">The following files have conflicts. Please choose how to resolve them.</p>
<div class="flex-1 overflow-y-auto space-y-6 mb-4 pr-2">
{#each conflicts as conflict}
<div class="border rounded-lg overflow-hidden">
<div class="bg-gray-100 px-4 py-2 font-medium border-b flex justify-between items-center">
<span>{conflict.file_path}</span>
{#if resolutions[conflict.file_path]}
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full uppercase font-bold">
Resolved: {resolutions[conflict.file_path]}
</span>
{/if}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 divide-x">
<div class="p-0 flex flex-col">
<div class="bg-blue-50 px-4 py-1 text-[10px] font-bold text-blue-600 uppercase border-b">Your Changes (Mine)</div>
<div class="p-4 bg-white flex-1 overflow-auto">
<pre class="text-xs font-mono whitespace-pre">{conflict.mine}</pre>
</div>
<button
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'mine' ? 'bg-blue-600 text-white' : 'bg-gray-50 hover:bg-blue-50 text-blue-600'}"
on:click={() => resolve(conflict.file_path, 'mine')}
>
Keep Mine
</button>
</div>
<div class="p-0 flex flex-col">
<div class="bg-green-50 px-4 py-1 text-[10px] font-bold text-green-600 uppercase border-b">Remote Changes (Theirs)</div>
<div class="p-4 bg-white flex-1 overflow-auto">
<pre class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre>
</div>
<button
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'theirs' ? 'bg-green-600 text-white' : 'bg-gray-50 hover:bg-green-50 text-green-600'}"
on:click={() => resolve(conflict.file_path, 'theirs')}
>
Keep Theirs
</button>
</div>
</div>
</div>
{/each}
</div>
<div class="flex justify-end space-x-3 pt-4 border-t">
<button
on:click={() => show = false}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded transition-colors"
>
Cancel
</button>
<button
on:click={handleSave}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors shadow-sm"
>
Resolve & Continue
</button>
</div>
</div>
</div>
{/if}
<!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:ConflictResolver:Component] -->

View File

@@ -0,0 +1,148 @@
<!-- [DEF:DeploymentModal:Component] -->
<!--
@SEMANTICS: deployment, git, environment, modal
@PURPOSE: Modal for deploying a dashboard to a target environment.
@LAYER: Component
@RELATION: CALLS -> frontend/src/services/gitService.js
@RELATION: DISPATCHES -> deploy
@INVARIANT: Cannot deploy without a selected environment.
-->
<script>
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let show = false;
// [/SECTION]
// [SECTION: STATE]
let environments = [];
let selectedEnv = '';
let loading = false;
let deploying = false;
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:loadStatus:Watcher]
$: if (show) loadEnvironments();
// [/DEF:loadStatus:Watcher]
// [DEF:loadEnvironments:Function]
/**
* @purpose Fetch available environments from API.
* @post environments state is populated.
* @side_effect Updates environments state.
*/
async function loadEnvironments() {
console.log(`[DeploymentModal][Action] Loading environments`);
loading = true;
try {
environments = await gitService.getEnvironments();
if (environments.length > 0) {
selectedEnv = environments[0].id;
}
console.log(`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`);
} catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast('Failed to load environments', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadEnvironments:Function]
// [DEF:handleDeploy:Function]
/**
* @purpose Trigger deployment to selected environment.
* @pre selectedEnv must be set.
* @post deploy event dispatched on success.
* @side_effect Triggers API call, closes modal, shows toast.
*/
async function handleDeploy() {
if (!selectedEnv) return;
console.log(`[DeploymentModal][Action] Deploying to ${selectedEnv}`);
deploying = true;
try {
const result = await gitService.deploy(dashboardId, selectedEnv);
toast(result.message || 'Deployment triggered successfully', 'success');
dispatch('deploy');
show = false;
console.log(`[DeploymentModal][Coherence:OK] Deployment triggered`);
} catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
} finally {
deploying = false;
}
}
// [/DEF:handleDeploy:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<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-96">
<h2 class="text-xl font-bold mb-4">Deploy Dashboard</h2>
{#if loading}
<p class="text-gray-500">Loading environments...</p>
{:else if environments.length === 0}
<p class="text-red-500 mb-4">No deployment environments configured.</p>
<div class="flex justify-end">
<button
on:click={() => show = false}
class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
>
Close
</button>
</div>
{:else}
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Select Target Environment</label>
<select
bind:value={selectedEnv}
class="w-full border rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
>
{#each environments as env}
<option value={env.id}>{env.name} ({env.superset_url})</option>
{/each}
</select>
</div>
<div class="flex justify-end space-x-3">
<button
on:click={() => show = false}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
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"
>
{#if deploying}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Deploying...
{:else}
Deploy
{/if}
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- [/SECTION] -->
<!-- [/DEF:DeploymentModal:Component] -->

View File

@@ -0,0 +1,300 @@
<!-- [DEF:GitManager:Component] -->
<!--
@SEMANTICS: git, manager, dashboard, version_control, initialization
@PURPOSE: Центральный компонент для управления Git-операциями конкретного дашборда.
@LAYER: Component
@RELATION: USES -> BranchSelector
@RELATION: USES -> CommitModal
@RELATION: USES -> CommitHistory
@RELATION: USES -> DeploymentModal
@RELATION: USES -> ConflictResolver
@RELATION: CALLS -> gitService
-->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n';
import { Button, Card, PageHeader, Select, Input } from '../../lib/ui';
import BranchSelector from './BranchSelector.svelte';
import CommitModal from './CommitModal.svelte';
import CommitHistory from './CommitHistory.svelte';
import DeploymentModal from './DeploymentModal.svelte';
import ConflictResolver from './ConflictResolver.svelte';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let dashboardTitle = "";
export let show = false;
// [/SECTION]
// [SECTION: STATE]
let currentBranch = 'main';
let showCommitModal = false;
let showDeployModal = false;
let showHistory = true;
let showConflicts = false;
let conflicts = [];
let loading = false;
let initialized = false;
let checkingStatus = true;
// Initialization form state
let configs = [];
let selectedConfigId = "";
let remoteUrl = "";
// [/SECTION]
// [DEF:checkStatus:Function]
/**
* @purpose Проверяет, инициализирован ли репозиторий для данного дашборда.
* @pre Component is mounted and has dashboardId.
* @post initialized state is set; configs loaded if not initialized.
*/
async function checkStatus() {
checkingStatus = true;
try {
// If we can get branches, it means repo exists
await gitService.getBranches(dashboardId);
initialized = true;
} catch (e) {
initialized = false;
// Load configs if not initialized
configs = await gitService.getConfigs();
if (configs.length > 0) selectedConfigId = configs[0].id;
} finally {
checkingStatus = false;
}
}
// [/DEF:checkStatus:Function]
// [DEF:handleInit:Function]
/**
* @purpose Инициализирует репозиторий для дашборда.
* @pre selectedConfigId and remoteUrl are provided.
* @post Repository is created on backend; initialized set to true.
*/
async function handleInit() {
if (!selectedConfigId || !remoteUrl) {
toast('Please select a Git server and provide remote URL', 'error');
return;
}
loading = true;
try {
await gitService.initRepository(dashboardId, selectedConfigId, remoteUrl);
toast('Repository initialized successfully', 'success');
initialized = true;
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handleInit:Function]
// [DEF:handleSync:Function]
/**
* @purpose Синхронизирует состояние Superset с локальным Git-репозиторием.
* @pre Repository is initialized.
* @post Dashboard YAMLs are exported to Git and staged.
*/
async function handleSync() {
loading = true;
try {
// Try to get selected environment from localStorage (set by EnvSelector)
const sourceEnvId = localStorage.getItem('selected_env_id');
await gitService.sync(dashboardId, sourceEnvId);
toast('Dashboard state synced to Git', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handleSync:Function]
// [DEF:handlePush:Function]
/**
* @purpose Pushes local commits to the remote repository.
* @pre Repository is initialized and has commits.
* @post Changes are pushed to origin.
*/
async function handlePush() {
loading = true;
try {
await gitService.push(dashboardId);
toast('Changes pushed to remote', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handlePush:Function]
// [DEF:handlePull:Function]
/**
* @purpose Pulls changes from the remote repository.
* @pre Repository is initialized.
* @post Local branch is updated with remote changes.
*/
async function handlePull() {
loading = true;
try {
await gitService.pull(dashboardId);
toast('Changes pulled from remote', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handlePull:Function]
onMount(checkStatus);
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<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-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<PageHeader title="{$t.git.management}: {dashboardTitle}">
<div slot="subtitle" class="text-sm text-gray-500">ID: {dashboardId}</div>
<div slot="actions">
<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>
</button>
</div>
</PageHeader>
{#if checkingStatus}
<div class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else if !initialized}
<div class="max-w-md mx-auto py-8">
<Card>
<p class="text-sm text-gray-600 mb-6">
{$t.git.not_linked}
</p>
<div class="space-y-6">
<Select
label={$t.git.server}
bind:value={selectedConfigId}
options={configs.map(c => ({ value: c.id, label: `${c.name} (${c.provider})` }))}
/>
{#if configs.length === 0}
<p class="text-xs text-red-500 -mt-4">No Git servers configured. Go to Settings -> Git to add one.</p>
{/if}
<Input
label={$t.git.remote_url}
bind:value={remoteUrl}
placeholder="https://github.com/org/repo.git"
/>
<Button
on:click={handleInit}
disabled={loading || configs.length === 0}
isLoading={loading}
class="w-full"
>
{$t.git.init_repo}
</Button>
</div>
</Card>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Left Column: Controls -->
<div class="md:col-span-1 space-y-6">
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">{$t.git.branch}</h3>
<BranchSelector {dashboardId} bind:currentBranch />
</section>
<section class="space-y-3">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">{$t.git.actions}</h3>
<Button
variant="secondary"
on:click={handleSync}
disabled={loading}
class="w-full"
>
{$t.git.sync}
</Button>
<Button
on:click={() => showCommitModal = true}
disabled={loading}
class="w-full"
>
{$t.git.commit}
</Button>
<div class="grid grid-cols-2 gap-3">
<Button
variant="ghost"
on:click={handlePull}
disabled={loading}
class="border border-gray-200"
>
{$t.git.pull}
</Button>
<Button
variant="ghost"
on:click={handlePush}
disabled={loading}
class="border border-gray-200"
>
{$t.git.push}
</Button>
</div>
</section>
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">{$t.git.deployment}</h3>
<Button
variant="primary"
on:click={() => showDeployModal = true}
disabled={loading}
class="w-full bg-green-600 hover:bg-green-700 focus-visible:ring-green-500"
>
{$t.git.deploy}
</Button>
</section>
</div>
<!-- Right Column: History -->
<div class="md:col-span-2 border-l pl-6">
<CommitHistory {dashboardId} />
</div>
</div>
{/if}
</div>
</div>
{/if}
<CommitModal
{dashboardId}
bind:show={showCommitModal}
on:commit={() => { /* Refresh history */ }}
/>
<DeploymentModal
{dashboardId}
bind:show={showDeployModal}
/>
<ConflictResolver
{conflicts}
bind:show={showConflicts}
on:resolve={() => { /* Handle resolution */ }}
/>
<!-- [/SECTION] -->
<!-- [/DEF:GitManager:Component] -->

View File

@@ -0,0 +1,134 @@
<!-- [DEF:FileList:Component] -->
<!--
@SEMANTICS: storage, files, list, table
@PURPOSE: Displays a table of files with metadata and actions.
@LAYER: Component
@RELATION: DEPENDS_ON -> storageService
@PROPS: files (Array) - List of StoredFile objects.
@EVENTS: delete (filename) - Dispatched when a file is deleted.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import { downloadFileUrl } from '../../services/storageService';
import { t } from '../../lib/i18n';
// [/SECTION: IMPORTS]
export let files = [];
const dispatch = createEventDispatcher();
// [DEF:isDirectory:Function]
/**
* @purpose Checks if a file object represents a directory.
* @param {Object} file - The file object to check.
* @return {boolean} True if it's a directory, false otherwise.
*/
function isDirectory(file) {
return file.mime_type === 'directory';
}
// [/DEF:isDirectory:Function]
// [DEF:formatSize:Function]
/**
* @purpose Formats file size in bytes into a human-readable string.
* @param {number} bytes - The size in bytes.
* @return {string} Formatted size (e.g., "1.2 MB").
*/
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// [/DEF:formatSize:Function]
// [DEF:formatDate:Function]
/**
* @purpose Formats an ISO date string into a localized readable format.
* @param {string} dateStr - The date string to format.
* @return {string} Localized date and time.
*/
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString();
}
// [/DEF:formatDate:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="overflow-x-auto">
<table class="min-w-full bg-white border border-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.storage.table.name}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.storage.table.category}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.storage.table.size}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.storage.table.created_at}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.storage.table.actions}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each files as file}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{#if isDirectory(file)}
<button
on:click={() => dispatch('navigate', file.path)}
class="flex items-center text-indigo-600 hover:text-indigo-900"
>
<svg class="h-5 w-5 mr-2 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</svg>
{file.name}
</button>
{:else}
<div class="flex items-center">
<svg class="h-5 w-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
{file.name}
</div>
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">{file.category}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{isDirectory(file) ? '--' : formatSize(file.size)}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatDate(file.created_at)}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{#if !isDirectory(file)}
<a
href={downloadFileUrl(file.category, file.path)}
download={file.name}
class="text-indigo-600 hover:text-indigo-900 mr-4"
>
{$t.storage.table.download}
</a>
{/if}
<button
on:click={() => dispatch('delete', { category: file.category, path: file.path, name: file.name })}
class="text-red-600 hover:text-red-900"
>
{$t.storage.table.delete}
</button>
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="px-6 py-10 text-center text-sm text-gray-500">
{$t.storage.no_files}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- [/SECTION: TEMPLATE] -->
<style>
/* ... */
</style>
<!-- [/DEF:FileList:Component] -->

View File

@@ -0,0 +1,134 @@
<!-- [DEF:FileUpload:Component] -->
<!--
@SEMANTICS: storage, upload, files
@PURPOSE: Provides a form for uploading files to a specific category.
@LAYER: Component
@RELATION: DEPENDS_ON -> storageService
@PROPS: None
@EVENTS: uploaded - Dispatched when a file is successfully uploaded.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import { uploadFile } from '../../services/storageService';
import { addToast } from '../../lib/toasts';
import { t } from '../../lib/i18n';
// [/SECTION: IMPORTS]
// [DEF:handleUpload:Function]
/**
* @purpose Handles the file upload process.
* @pre A file must be selected in the file input.
* @post The file is uploaded to the server and a success toast is shown.
*/
const dispatch = createEventDispatcher();
let fileInput;
export let category = 'backups';
export let path = '';
let isUploading = false;
let dragOver = false;
async function handleUpload() {
const file = fileInput.files[0];
if (!file) return;
isUploading = true;
try {
// path is relative to root, but upload endpoint expects path within category
// FileList.path is like "backup/folder", we need just "folder"
const subpath = path.startsWith(category)
? path.substring(category.length).replace(/^\/+/, '')
: path;
await uploadFile(file, category, subpath);
addToast($t.storage.messages.upload_success.replace('{name}', file.name), 'success');
fileInput.value = '';
dispatch('uploaded');
} catch (error) {
addToast($t.storage.messages.upload_failed.replace('{error}', error.message), 'error');
} finally {
isUploading = false;
}
}
// [/DEF:handleUpload:Function]
// [DEF:handleDrop:Function]
/**
* @purpose Handles the file drop event for drag-and-drop.
* @param {DragEvent} event - The drop event.
*/
function handleDrop(event) {
event.preventDefault();
dragOver = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
handleUpload();
}
}
// [/DEF:handleDrop:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h2 class="text-lg font-semibold mb-4">{$t.storage.upload_title}</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.storage.target_category}</label>
<select
bind:value={category}
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="backups">{$t.storage.backups}</option>
<option value="repositorys">{$t.storage.repositories}</option>
</select>
</div>
<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'}"
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">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-gray-600">
<label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
<span>{$t.storage.upload_button}</span>
<input
id="file-upload"
name="file-upload"
type="file"
class="sr-only"
bind:this={fileInput}
on:change={handleUpload}
disabled={isUploading}
>
</label>
<p class="pl-1">{$t.storage.drag_drop}</p>
</div>
<p class="text-xs text-gray-500">{$t.storage.supported_formats}</p>
</div>
</div>
{#if isUploading}
<div class="flex items-center justify-center space-x-2 text-indigo-600">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600"></div>
<span class="text-sm font-medium">{$t.storage.uploading}</span>
</div>
{/if}
</div>
</div>
<!-- [/SECTION: TEMPLATE] -->
<style>
/* ... */
</style>
<!-- [/DEF:FileUpload:Component] -->

View File

@@ -10,6 +10,8 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { createConnection } from '../../services/connectionService.js'; import { createConnection } from '../../services/connectionService.js';
import { addToast } from '../../lib/toasts.js'; import { addToast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n';
import { Button, Input, Card } from '../../lib/ui';
// [/SECTION] // [/SECTION]
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -17,7 +19,7 @@
let name = ''; let name = '';
let type = 'postgres'; let type = 'postgres';
let host = ''; let host = '';
let port = 5432; let port = "5432";
let database = ''; let database = '';
let username = ''; let username = '';
let password = ''; let password = '';
@@ -36,7 +38,7 @@
isSubmitting = true; isSubmitting = true;
try { try {
const newConnection = await createConnection({ const newConnection = await createConnection({
name, type, host, port, database, username, password name, type, host, port: Number(port), database, username, password
}); });
addToast('Connection created successfully', 'success'); addToast('Connection created successfully', 'success');
dispatch('success', newConnection); dispatch('success', newConnection);
@@ -57,7 +59,7 @@
function resetForm() { function resetForm() {
name = ''; name = '';
host = ''; host = '';
port = 5432; port = "5432";
database = ''; database = '';
username = ''; username = '';
password = ''; password = '';
@@ -66,43 +68,28 @@
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="bg-white p-6 rounded-lg shadow-sm border border-gray-200"> <Card title={$t.connections?.add_new || "Add New Connection"}>
<h3 class="text-lg font-medium text-gray-900 mb-4">Add New Connection</h3> <form on:submit|preventDefault={handleSubmit} class="space-y-6">
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <Input label={$t.connections?.name || "Connection Name"} bind:value={name} placeholder="e.g. Production DWH" />
<div>
<label for="conn-name" class="block text-sm font-medium text-gray-700">Connection Name</label> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<input type="text" id="conn-name" bind:value={name} placeholder="e.g. Production DWH" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> <Input label={$t.connections?.host || "Host"} bind:value={host} placeholder="10.0.0.1" />
<Input label={$t.connections?.port || "Port"} type="number" bind:value={port} />
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <Input label={$t.connections?.db_name || "Database Name"} bind:value={database} />
<label for="conn-host" class="block text-sm font-medium text-gray-700">Host</label>
<input type="text" id="conn-host" bind:value={host} placeholder="10.0.0.1" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
</div> <Input label={$t.connections?.user || "Username"} bind:value={username} />
<div> <Input label={$t.connections?.pass || "Password"} type="password" bind:value={password} />
<label for="conn-port" class="block text-sm font-medium text-gray-700">Port</label>
<input type="number" id="conn-port" bind:value={port} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
</div>
<div>
<label for="conn-db" class="block text-sm font-medium text-gray-700">Database Name</label>
<input type="text" id="conn-db" bind:value={database} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="conn-user" class="block text-sm font-medium text-gray-700">Username</label>
<input type="text" id="conn-user" bind:value={username} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
<div>
<label for="conn-pass" class="block text-sm font-medium text-gray-700">Password</label>
<input type="password" id="conn-pass" bind:value={password} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
</div> </div>
<div class="flex justify-end pt-2"> <div class="flex justify-end pt-2">
<button type="submit" disabled={isSubmitting} class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"> <Button type="submit" disabled={isSubmitting} isLoading={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Connection'} {$t.connections?.create || "Create Connection"}
</button> </Button>
</div> </div>
</form> </form>
</div> </Card>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<!-- [/DEF:ConnectionForm:Component] --> <!-- [/DEF:ConnectionForm:Component] -->

View File

@@ -10,6 +10,8 @@
import { onMount, createEventDispatcher } from 'svelte'; import { onMount, createEventDispatcher } from 'svelte';
import { getConnections, deleteConnection } from '../../services/connectionService.js'; import { getConnections, deleteConnection } from '../../services/connectionService.js';
import { addToast } from '../../lib/toasts.js'; import { addToast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n';
import { Button, Card } from '../../lib/ui';
// [/SECTION] // [/SECTION]
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -57,32 +59,30 @@
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="bg-white shadow overflow-hidden sm:rounded-md border border-gray-200"> <Card title={$t.connections?.saved || "Saved Connections"} padding="none">
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200"> <ul class="divide-y divide-gray-100">
<h3 class="text-lg leading-6 font-medium text-gray-900">Saved Connections</h3>
</div>
<ul class="divide-y divide-gray-200">
{#if isLoading} {#if isLoading}
<li class="p-4 text-center text-gray-500">Loading...</li> <li class="p-6 text-center text-gray-500">{$t.common.loading}</li>
{:else if connections.length === 0} {:else if connections.length === 0}
<li class="p-8 text-center text-gray-500 italic">No connections saved yet.</li> <li class="p-12 text-center text-gray-500 italic">{$t.connections?.no_saved || "No connections saved yet."}</li>
{:else} {:else}
{#each connections as conn} {#each connections as conn}
<li class="p-4 flex items-center justify-between hover:bg-gray-50"> <li class="p-6 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div> <div>
<div class="text-sm font-medium text-indigo-600 truncate">{conn.name}</div> <div class="text-sm font-medium text-blue-600 truncate">{conn.name}</div>
<div class="text-xs text-gray-500">{conn.type}://{conn.username}@{conn.host}:{conn.port}/{conn.database}</div> <div class="text-xs text-gray-400 mt-1 font-mono">{conn.type}://{conn.username}@{conn.host}:{conn.port}/{conn.database}</div>
</div> </div>
<button <Button
variant="danger"
size="sm"
on:click={() => handleDelete(conn.id)} on:click={() => handleDelete(conn.id)}
class="ml-2 inline-flex items-center px-2 py-1 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
> >
Delete {$t.connections?.delete || "Delete"}
</button> </Button>
</li> </li>
{/each} {/each}
{/if} {/if}
</ul> </ul>
</div> </Card>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<!-- [/DEF:ConnectionList:Component] --> <!-- [/DEF:ConnectionList:Component] -->

View File

@@ -13,6 +13,8 @@
import { getConnections } from '../../services/connectionService.js'; import { getConnections } from '../../services/connectionService.js';
import { selectedTask } from '../../lib/stores.js'; import { selectedTask } from '../../lib/stores.js';
import { addToast } from '../../lib/toasts.js'; import { addToast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n';
import { Button, Card, Select, Input } from '../../lib/ui';
// [/SECTION] // [/SECTION]
let envs = []; let envs = [];
@@ -36,7 +38,7 @@
envs = await envsRes.json(); envs = await envsRes.json();
connections = await getConnections(); connections = await getConnections();
} catch (e) { } catch (e) {
addToast('Failed to fetch data', 'error'); addToast($t.mapper.errors.fetch_failed, 'error');
} }
} }
// [/DEF:fetchData:Function] // [/DEF:fetchData:Function]
@@ -47,17 +49,17 @@
// @POST: Mapper task is started and selectedTask is updated. // @POST: Mapper task is started and selectedTask is updated.
async function handleRunMapper() { async function handleRunMapper() {
if (!selectedEnv || !datasetId) { if (!selectedEnv || !datasetId) {
addToast('Please fill in required fields', 'warning'); addToast($t.mapper.errors.required_fields, 'warning');
return; return;
} }
if (source === 'postgres' && (!selectedConnection || !tableName)) { if (source === 'postgres' && (!selectedConnection || !tableName)) {
addToast('Connection and Table Name are required for postgres source', 'warning'); addToast($t.mapper.errors.postgres_required, 'warning');
return; return;
} }
if (source === 'excel' && !excelPath) { if (source === 'excel' && !excelPath) {
addToast('Excel path is required for excel source', 'warning'); addToast($t.mapper.errors.excel_required, 'warning');
return; return;
} }
@@ -75,7 +77,7 @@
}); });
selectedTask.set(task); selectedTask.set(task);
addToast('Mapper task started', 'success'); addToast($t.mapper.success.started, 'success');
} catch (e) { } catch (e) {
addToast(e.message, 'error'); addToast(e.message, 'error');
} finally { } finally {
@@ -88,78 +90,94 @@
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="bg-white p-6 rounded-lg shadow-sm border border-gray-200"> <div class="space-y-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Dataset Column Mapper</h3> <Card title={$t.mapper.title}>
<div class="space-y-4"> <div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="mapper-env" class="block text-sm font-medium text-gray-700">Environment</label>
<select id="mapper-env" bind:value={selectedEnv} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<option value="" disabled>-- Select Environment --</option>
{#each envs as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<div>
<label for="mapper-ds-id" class="block text-sm font-medium text-gray-700">Dataset ID</label>
<input type="number" id="mapper-ds-id" bind:value={datasetId} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Mapping Source</label>
<div class="mt-2 flex space-x-4">
<label class="inline-flex items-center">
<input type="radio" bind:group={source} value="postgres" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300" />
<span class="ml-2 text-sm text-gray-700">PostgreSQL</span>
</label>
<label class="inline-flex items-center">
<input type="radio" bind:group={source} value="excel" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300" />
<span class="ml-2 text-sm text-gray-700">Excel</span>
</label>
</div>
</div>
{#if source === 'postgres'}
<div class="space-y-4 p-4 bg-gray-50 rounded-md border border-gray-100">
<div> <div>
<label for="mapper-conn" class="block text-sm font-medium text-gray-700">Saved Connection</label> <Select
<select id="mapper-conn" bind:value={selectedConnection} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> label={$t.mapper.environment}
<option value="" disabled>-- Select Connection --</option> bind:value={selectedEnv}
{#each connections as conn} options={[
<option value={conn.id}>{conn.name}</option> { value: '', label: $t.mapper.select_env },
{/each} ...envs.map(e => ({ value: e.id, label: e.name }))
</select> ]}
/>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div>
<div> <Input
<label for="mapper-table" class="block text-sm font-medium text-gray-700">Table Name</label> label={$t.mapper.dataset_id}
<input type="text" id="mapper-table" bind:value={tableName} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> type="number"
</div> bind:value={datasetId}
<div> />
<label for="mapper-schema" class="block text-sm font-medium text-gray-700">Table Schema</label>
<input type="text" id="mapper-schema" bind:value={tableSchema} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
</div> </div>
</div> </div>
{:else}
<div class="p-4 bg-gray-50 rounded-md border border-gray-100">
<label for="mapper-excel" class="block text-sm font-medium text-gray-700">Excel File Path</label>
<input type="text" id="mapper-excel" bind:value={excelPath} placeholder="/path/to/mapping.xlsx" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
{/if}
<div class="flex justify-end"> <div>
<button <label class="block text-sm font-medium text-gray-700 mb-2">{$t.mapper.source}</label>
on:click={handleRunMapper} <div class="flex space-x-4">
disabled={isRunning} <label class="inline-flex items-center">
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50" <input type="radio" bind:group={source} value="postgres" class="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300" />
> <span class="ml-2 text-sm text-gray-700">{$t.mapper.source_postgres}</span>
{isRunning ? 'Starting...' : 'Run Mapper'} </label>
</button> <label class="inline-flex items-center">
<input type="radio" bind:group={source} value="excel" class="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300" />
<span class="ml-2 text-sm text-gray-700">{$t.mapper.source_excel}</span>
</label>
</div>
</div>
{#if source === 'postgres'}
<div class="space-y-4 p-4 bg-gray-50 rounded-md border border-gray-100">
<div>
<Select
label={$t.mapper.connection}
bind:value={selectedConnection}
options={[
{ value: '', label: $t.mapper.select_connection },
...connections.map(c => ({ value: c.id, label: c.name }))
]}
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Input
label={$t.mapper.table_name}
type="text"
bind:value={tableName}
/>
</div>
<div>
<Input
label={$t.mapper.table_schema}
type="text"
bind:value={tableSchema}
/>
</div>
</div>
</div>
{:else}
<div class="p-4 bg-gray-50 rounded-md border border-gray-100">
<Input
label={$t.mapper.excel_path}
type="text"
bind:value={excelPath}
placeholder="/path/to/mapping.xlsx"
/>
</div>
{/if}
<div class="flex justify-end pt-2">
<Button
variant="primary"
on:click={handleRunMapper}
disabled={isRunning}
>
{isRunning ? $t.mapper.starting : $t.mapper.run}
</Button>
</div>
</div> </div>
</div> </Card>
</div> </div>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<!-- [/DEF:MapperTool:Component] --> <!-- [/DEF:MapperTool:Component] -->

View File

@@ -1,186 +0,0 @@
<!-- [DEF:SearchTool:Component] -->
<!--
@SEMANTICS: search, tool, dataset, regex
@PURPOSE: UI component for searching datasets using the SearchPlugin.
@LAYER: UI
@RELATION: USES -> frontend/src/services/toolsService.js
-->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { runTask, getTaskStatus } from '../../services/toolsService.js';
import { selectedTask } from '../../lib/stores.js';
import { addToast } from '../../lib/toasts.js';
// [/SECTION]
let envs = [];
let selectedEnv = '';
let searchQuery = '';
let isRunning = false;
let results = null;
let pollInterval;
// [DEF:fetchEnvironments:Function]
// @PURPOSE: Fetches the list of available environments.
// @PRE: None.
// @POST: envs array is populated.
async function fetchEnvironments() {
try {
const res = await fetch('/api/environments');
envs = await res.json();
} catch (e) {
addToast('Failed to fetch environments', 'error');
}
}
// [/DEF:fetchEnvironments:Function]
// [DEF:handleSearch:Function]
// @PURPOSE: Triggers the SearchPlugin task.
// @PRE: selectedEnv and searchQuery must be set.
// @POST: Task is started and polling begins.
async function handleSearch() {
if (!selectedEnv || !searchQuery) {
addToast('Please select environment and enter query', 'warning');
return;
}
isRunning = true;
results = null;
try {
// Find the environment name from ID
const env = envs.find(e => e.id === selectedEnv);
const task = await runTask('search-datasets', {
env: env.name,
query: searchQuery
});
selectedTask.set(task);
startPolling(task.id);
} catch (e) {
isRunning = false;
addToast(e.message, 'error');
}
}
// [/DEF:handleSearch:Function]
// [DEF:startPolling:Function]
// @PURPOSE: Polls for task completion and results.
// @PRE: taskId is provided.
// @POST: pollInterval is set and results are updated on success.
function startPolling(taskId) {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(async () => {
try {
const task = await getTaskStatus(taskId);
selectedTask.set(task);
if (task.status === 'SUCCESS') {
clearInterval(pollInterval);
isRunning = false;
results = task.result;
addToast('Search completed', 'success');
} else if (task.status === 'FAILED') {
clearInterval(pollInterval);
isRunning = false;
addToast('Search failed', 'error');
}
} catch (e) {
clearInterval(pollInterval);
isRunning = false;
addToast('Error polling task status', 'error');
}
}, 2000);
}
// [/DEF:startPolling:Function]
onMount(fetchEnvironments);
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="space-y-6">
<div class="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 class="text-lg font-medium text-gray-900 mb-4">Search Dataset Metadata</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
<div>
<label for="env-select" class="block text-sm font-medium text-gray-700">Environment</label>
<select
id="env-select"
bind:value={selectedEnv}
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
>
<option value="" disabled>-- Select Environment --</option>
{#each envs as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<div>
<label for="search-query" class="block text-sm font-medium text-gray-700">Regex Pattern</label>
<input
type="text"
id="search-query"
bind:value={searchQuery}
placeholder="e.g. from dm.*\.account"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
on:click={handleSearch}
disabled={isRunning}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{#if isRunning}
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Searching...
{:else}
Search
{/if}
</button>
</div>
</div>
{#if results}
<div class="bg-white shadow overflow-hidden sm:rounded-md border border-gray-200">
<div class="px-4 py-5 sm:px-6 flex justify-between items-center bg-gray-50 border-b border-gray-200">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Search Results
</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{results.count} matches
</span>
</div>
<ul class="divide-y divide-gray-200">
{#each results.results as item}
<li class="p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-indigo-600 truncate">
{item.dataset_name} (ID: {item.dataset_id})
</div>
<div class="ml-2 flex-shrink-0 flex">
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Field: {item.field}
</p>
</div>
</div>
<div class="mt-2">
<pre class="text-xs text-gray-500 bg-gray-50 p-2 rounded border border-gray-100 overflow-x-auto">{item.match_context}</pre>
</div>
</li>
{/each}
{#if results.count === 0}
<li class="p-8 text-center text-gray-500 italic">
No matches found for the given pattern.
</li>
{/if}
</ul>
</div>
{/if}
</div>
<!-- [/SECTION] -->
<!-- [/DEF:SearchTool:Component] -->

View File

@@ -95,7 +95,10 @@ async function requestApi(endpoint, method = 'GET', body = null) {
const response = await fetch(`${API_BASE_URL}${endpoint}`, options); const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `API request failed with status ${response.status}`); const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
throw new Error(message);
} }
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
@@ -123,6 +126,8 @@ export const api = {
deleteEnvironment: (id) => requestApi(`/settings/environments/${id}`, 'DELETE'), deleteEnvironment: (id) => requestApi(`/settings/environments/${id}`, 'DELETE'),
testEnvironmentConnection: (id) => postApi(`/settings/environments/${id}/test`, {}), testEnvironmentConnection: (id) => postApi(`/settings/environments/${id}/test`, {}),
updateEnvironmentSchedule: (id, schedule) => requestApi(`/environments/${id}/schedule`, 'PUT', schedule), updateEnvironmentSchedule: (id, schedule) => requestApi(`/environments/${id}/schedule`, 'PUT', schedule),
getStorageSettings: () => fetchApi('/settings/storage'),
updateStorageSettings: (storage) => requestApi('/settings/storage', 'PUT', storage),
getEnvironmentsList: () => fetchApi('/environments'), getEnvironmentsList: () => fetchApi('/environments'),
}; };
// [/DEF:api:Data] // [/DEF:api:Data]
@@ -130,6 +135,7 @@ export const api = {
// [/DEF:api_module:Module] // [/DEF:api_module:Module]
// Export individual functions for easier use in components // Export individual functions for easier use in components
export { requestApi };
export const getPlugins = api.getPlugins; export const getPlugins = api.getPlugins;
export const getTasks = api.getTasks; export const getTasks = api.getTasks;
export const getTask = api.getTask; export const getTask = api.getTask;
@@ -143,3 +149,5 @@ export const deleteEnvironment = api.deleteEnvironment;
export const testEnvironmentConnection = api.testEnvironmentConnection; export const testEnvironmentConnection = api.testEnvironmentConnection;
export const updateEnvironmentSchedule = api.updateEnvironmentSchedule; export const updateEnvironmentSchedule = api.updateEnvironmentSchedule;
export const getEnvironmentsList = api.getEnvironmentsList; export const getEnvironmentsList = api.getEnvironmentsList;
export const getStorageSettings = api.getStorageSettings;
export const updateStorageSettings = api.updateStorageSettings;

View File

@@ -1,18 +0,0 @@
// [DEF:main:Module]
// @SEMANTICS: entrypoint, svelte, init
// @PURPOSE: Entry point for the Svelte application.
// @LAYER: UI-Entry
import './app.css'
import App from './App.svelte'
// [DEF:app_instance:Data]
// @PURPOSE: Initialized Svelte app instance.
const app = new App({
target: document.getElementById('app'),
props: {}
})
// [/DEF:app_instance:Data]
export default app
// [/DEF:main:Module]

View File

@@ -20,7 +20,6 @@
let settings = { let settings = {
environments: [], environments: [],
settings: { settings: {
backup_path: '',
default_environment_id: null, default_environment_id: null,
logging: { logging: {
level: 'INFO', level: 'INFO',
@@ -204,12 +203,6 @@
<section class="mb-8 bg-white p-6 rounded shadow"> <section class="mb-8 bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Global Settings</h2> <h2 class="text-xl font-semibold mb-4">Global Settings</h2>
<div class="grid grid-cols-1 gap-4">
<div>
<label for="backup_path" class="block text-sm font-medium text-gray-700">Backup Storage Path</label>
<input type="text" id="backup_path" bind:value={settings.settings.backup_path} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
</div>
</div>
<h3 class="text-lg font-medium mb-4 mt-6">Logging Configuration</h3> <h3 class="text-lg font-medium mb-4 mt-6">Logging Configuration</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -5,6 +5,8 @@
import { api } from '../lib/api.js'; import { api } from '../lib/api.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { t } from '$lib/i18n';
import { Button, Card, PageHeader } from '$lib/ui';
/** @type {import('./$types').PageData} */ /** @type {import('./$types').PageData} */
export let data; export let data;
@@ -21,8 +23,8 @@
*/ */
function selectPlugin(plugin) { function selectPlugin(plugin) {
console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`); console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`);
if (plugin.id === 'superset-migration') { if (plugin.ui_route) {
goto('/migration'); goto(plugin.ui_route);
} else { } else {
selectedPlugin.set(plugin); selectedPlugin.set(plugin);
} }
@@ -53,34 +55,43 @@
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
{#if $selectedTask} {#if $selectedTask}
<TaskRunner /> <TaskRunner />
<button on:click={() => selectedTask.set(null)} class="mt-4 bg-blue-500 text-white p-2 rounded"> <div class="mt-4">
Back to Task List <Button variant="primary" on:click={() => selectedTask.set(null)}>
</button> {$t.common.cancel}
</Button>
</div>
{:else if $selectedPlugin} {:else if $selectedPlugin}
<h2 class="text-2xl font-bold mb-4">{$selectedPlugin.name}</h2> <PageHeader title={$selectedPlugin.name} />
<DynamicForm schema={$selectedPlugin.schema} on:submit={handleFormSubmit} /> <Card>
<button on:click={() => selectedPlugin.set(null)} class="mt-4 bg-gray-500 text-white p-2 rounded"> <DynamicForm schema={$selectedPlugin.schema} on:submit={handleFormSubmit} />
Back to Dashboard </Card>
</button> <div class="mt-4">
<Button variant="secondary" on:click={() => selectedPlugin.set(null)}>
{$t.common.cancel}
</Button>
</div>
{:else} {:else}
<h1 class="text-2xl font-bold mb-4">Available Tools</h1> <PageHeader title={$t.nav.dashboard} />
{#if data.error} {#if data.error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{data.error} {data.error}
</div> </div>
{/if} {/if}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each data.plugins as plugin} <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div {#each data.plugins.filter(p => p.id !== 'superset-search') as plugin}
class="border rounded-lg p-4 cursor-pointer hover:bg-gray-100" <div
on:click={() => selectPlugin(plugin)} on:click={() => selectPlugin(plugin)}
role="button" role="button"
tabindex="0" tabindex="0"
on:keydown={(e) => e.key === 'Enter' && selectPlugin(plugin)} on:keydown={(e) => e.key === 'Enter' && selectPlugin(plugin)}
class="cursor-pointer transition-transform hover:scale-[1.02]"
> >
<h2 class="text-xl font-semibold">{plugin.name}</h2> <Card title={plugin.name}>
<p class="text-gray-600">{plugin.description}</p> <p class="text-gray-600 mb-4">{plugin.description}</p>
<span class="text-sm text-gray-400">v{plugin.version}</span> <span class="text-xs font-mono text-gray-400 bg-gray-50 px-2 py-1 rounded">v{plugin.version}</span>
</Card>
</div> </div>
{/each} {/each}
</div> </div>

View File

@@ -0,0 +1,96 @@
<!-- [DEF:GitDashboardPage:Component] -->
<!--
@PURPOSE: Dashboard management page for Git integration.
@LAYER: Page
@SEMANTICS: git, dashboard, management, ui
-->
<script lang="ts">
import { onMount } from 'svelte';
import DashboardGrid from '../../components/DashboardGrid.svelte';
import { addToast as toast } from '../../lib/toasts.js';
import type { DashboardMetadata } from '../../types/dashboard';
import { t } from '$lib/i18n';
import { Button, Card, PageHeader, Select } from '$lib/ui';
let environments: any[] = [];
let selectedEnvId = "";
let dashboards: DashboardMetadata[] = [];
let loading = true;
let fetchingDashboards = false;
// [DEF:fetchEnvironments:Function]
// @PURPOSE: Fetches the list of deployment environments from the API.
// @PRE: Component is mounted.
// @POST: `environments` array is populated with data from /api/environments.
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
if (environments.length > 0) {
selectedEnvId = environments[0].id;
}
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:fetchEnvironments:Function]
// [DEF:fetchDashboards:Function]
// @PURPOSE: Fetches dashboards for a specific environment.
// @PRE: `envId` is a valid environment ID.
// @POST: `dashboards` array is updated with results from the environment.
async function fetchDashboards(envId: string) {
if (!envId) return;
fetchingDashboards = true;
try {
const response = await fetch(`/api/environments/${envId}/dashboards`);
if (!response.ok) throw new Error('Failed to fetch dashboards');
dashboards = await response.json();
} catch (e) {
toast(e.message, 'error');
dashboards = [];
} finally {
fetchingDashboards = false;
}
}
// [/DEF:fetchDashboards:Function]
onMount(fetchEnvironments);
$: if (selectedEnvId) {
fetchDashboards(selectedEnvId);
localStorage.setItem('selected_env_id', selectedEnvId);
}
</script>
<div class="max-w-6xl mx-auto p-6">
<PageHeader title="Git Dashboard Management">
<div slot="actions" class="flex items-center space-x-4">
<Select
label="Environment"
bind:value={selectedEnvId}
options={environments.map(e => ({ value: e.id, label: e.name }))}
/>
</div>
</PageHeader>
{#if loading}
<div class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else}
<Card title="Select Dashboard to Manage">
{#if fetchingDashboards}
<p class="text-gray-500">Loading dashboards...</p>
{:else if dashboards.length > 0}
<DashboardGrid {dashboards} />
{:else}
<p class="text-gray-500 italic">No dashboards found in this environment.</p>
{/if}
</Card>
{/if}
</div>
<!-- [/DEF:GitDashboardPage:Component] -->

View File

@@ -21,6 +21,8 @@
import { selectedTask } from '../../lib/stores.js'; import { selectedTask } from '../../lib/stores.js';
import { resumeTask } from '../../services/taskService.js'; import { resumeTask } from '../../services/taskService.js';
import type { DashboardMetadata, DashboardSelection } from '../../types/dashboard'; import type { DashboardMetadata, DashboardSelection } from '../../types/dashboard';
import { t } from '$lib/i18n';
import { Button, Card, PageHeader } from '$lib/ui';
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]
@@ -294,19 +296,18 @@
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="max-w-4xl mx-auto p-6"> <div class="max-w-4xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Migration Dashboard</h1> <PageHeader title={$t.nav.migration} />
<TaskHistory on:viewLogs={handleViewLogs} /> <TaskHistory on:viewLogs={handleViewLogs} />
{#if $selectedTask} {#if $selectedTask}
<div class="mt-6"> <div class="mt-6">
<TaskRunner /> <TaskRunner />
<button <div class="mt-4">
on:click={() => selectedTask.set(null)} <Button variant="secondary" on:click={() => selectedTask.set(null)}>
class="mt-4 inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" {$t.common.cancel}
> </Button>
Back to New Migration </div>
</button>
</div> </div>
{:else} {:else}
{#if loading} {#if loading}
@@ -383,13 +384,12 @@
</div> </div>
{/if} {/if}
<button <Button
on:click={startMigration} on:click={startMigration}
disabled={!sourceEnvId || !targetEnvId || sourceEnvId === targetEnvId || selectedDashboardIds.length === 0} disabled={!sourceEnvId || !targetEnvId || sourceEnvId === targetEnvId || selectedDashboardIds.length === 0}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400"
> >
Start Migration Start Migration
</button> </Button>
{/if} {/if}
</div> </div>

View File

@@ -14,6 +14,8 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import EnvSelector from '../../../components/EnvSelector.svelte'; import EnvSelector from '../../../components/EnvSelector.svelte';
import MappingTable from '../../../components/MappingTable.svelte'; import MappingTable from '../../../components/MappingTable.svelte';
import { t } from '$lib/i18n';
import { Button, PageHeader } from '$lib/ui';
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]
@@ -128,7 +130,7 @@
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="max-w-6xl mx-auto p-6"> <div class="max-w-6xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Database Mapping Management</h1> <PageHeader title="Database Mapping Management" />
{#if loading} {#if loading}
<p>Loading environments...</p> <p>Loading environments...</p>
@@ -149,13 +151,13 @@
</div> </div>
<div class="mb-8"> <div class="mb-8">
<button <Button
on:click={fetchDatabases} on:click={fetchDatabases}
disabled={!sourceEnvId || !targetEnvId || sourceEnvId === targetEnvId || fetchingDbs} disabled={!sourceEnvId || !targetEnvId || sourceEnvId === targetEnvId || fetchingDbs}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400" isLoading={fetchingDbs}
> >
{fetchingDbs ? 'Fetching...' : 'Fetch Databases & Suggestions'} Fetch Databases & Suggestions
</button> </Button>
</div> </div>
{#if error} {#if error}

View File

@@ -1,7 +1,9 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { updateGlobalSettings, addEnvironment, updateEnvironment, deleteEnvironment, testEnvironmentConnection } from '../../lib/api'; import { updateGlobalSettings, addEnvironment, updateEnvironment, deleteEnvironment, testEnvironmentConnection, updateStorageSettings } from '../../lib/api';
import { addToast } from '../../lib/toasts'; import { addToast } from '../../lib/toasts';
import { t } from '$lib/i18n';
import { Button, Input, Card, PageHeader } from '$lib/ui';
/** @type {import('./$types').PageData} */ /** @type {import('./$types').PageData} */
export let data; export let data;
@@ -39,6 +41,24 @@
} }
// [/DEF:handleSaveGlobal:Function] // [/DEF:handleSaveGlobal:Function]
// [DEF:handleSaveStorage:Function]
/* @PURPOSE: Saves storage-specific settings.
@PRE: settings.settings.storage must contain valid configuration.
@POST: Storage settings are updated via API.
*/
async function handleSaveStorage() {
try {
console.log("[Settings.handleSaveStorage][Action] Saving storage settings.");
await updateStorageSettings(settings.settings.storage);
addToast('Storage settings saved', 'success');
console.log("[Settings.handleSaveStorage][Coherence:OK] Storage settings saved.");
} catch (error) {
console.error("[Settings.handleSaveStorage][Coherence:Failed] Failed to save storage settings:", error);
addToast(error.message || 'Failed to save storage settings', 'error');
}
}
// [/DEF:handleSaveStorage:Function]
// [DEF:handleAddOrUpdateEnv:Function] // [DEF:handleAddOrUpdateEnv:Function]
/* @PURPOSE: Adds a new environment or updates an existing one. /* @PURPOSE: Adds a new environment or updates an existing one.
@PRE: newEnv must contain valid environment details. @PRE: newEnv must contain valid environment details.
@@ -142,7 +162,7 @@
</script> </script>
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-6">Settings</h1> <PageHeader title={$t.settings.title} />
{#if data.error} {#if data.error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
@@ -150,38 +170,62 @@
</div> </div>
{/if} {/if}
<section class="mb-8 bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Global Settings</h2> <div class="mb-8">
<div class="grid grid-cols-1 gap-4"> <Card title={$t.settings?.storage_title || "File Storage Configuration"}>
<div> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<label for="backup_path" class="block text-sm font-medium text-gray-700">Backup Storage Path</label> <div class="md:col-span-2">
<input type="text" id="backup_path" bind:value={settings.settings.backup_path} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> <Input
label={$t.settings?.storage_root || "Storage Root Path"}
bind:value={settings.settings.storage.root_path}
/>
</div>
<Input
label={$t.settings?.storage_backup_pattern || "Backup Directory Pattern"}
bind:value={settings.settings.storage.backup_structure_pattern}
/>
<Input
label={$t.settings?.storage_repo_pattern || "Repository Directory Pattern"}
bind:value={settings.settings.storage.repo_structure_pattern}
/>
<Input
label={$t.settings?.storage_filename_pattern || "Filename Pattern"}
bind:value={settings.settings.storage.filename_pattern}
/>
<div class="bg-gray-50 p-4 rounded border border-gray-200">
<span class="block text-xs font-semibold text-gray-500 uppercase mb-2">{$t.settings?.storage_preview || "Path Preview"}</span>
<code class="text-sm text-indigo-600">
{settings.settings.storage.root_path}/backups/sample_backup.zip
</code>
</div>
</div> </div>
<button on:click={handleSaveGlobal} class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 w-max"> <div class="mt-6">
Save Global Settings <Button on:click={handleSaveStorage}>
</button> {$t.common.save}
</Button>
</div>
</Card>
</div>
<section class="mb-8">
<Card title={$t.settings?.env_title || "Superset Environments"}>
{#if settings.environments.length === 0}
<div class="mb-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700">
<p class="font-bold">Warning</p>
<p>{$t.settings?.env_warning || "No Superset environments configured."}</p>
</div> </div>
</section> {/if}
<section class="mb-8 bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Superset Environments</h2>
{#if settings.environments.length === 0}
<div class="mb-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700">
<p class="font-bold">Warning</p>
<p>No Superset environments configured. You must add at least one environment to perform backups or migrations.</p>
</div>
{/if}
<div class="mb-6 overflow-x-auto"> <div class="mb-6 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.connections?.name || "Name"}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">URL</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">URL</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.connections?.user || "Username"}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Default</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Default</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.git?.actions || "Actions"}</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@@ -192,9 +236,9 @@
<td class="px-6 py-4 whitespace-nowrap">{env.username}</td> <td class="px-6 py-4 whitespace-nowrap">{env.username}</td>
<td class="px-6 py-4 whitespace-nowrap">{env.is_default ? 'Yes' : 'No'}</td> <td class="px-6 py-4 whitespace-nowrap">{env.is_default ? 'Yes' : 'No'}</td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<button on:click={() => handleTestEnv(env.id)} class="text-green-600 hover:text-green-900 mr-4">Test</button> <button on:click={() => handleTestEnv(env.id)} class="text-green-600 hover:text-green-900 mr-4">{$t.settings?.env_test || "Test"}</button>
<button on:click={() => editEnv(env)} class="text-indigo-600 hover:text-indigo-900 mr-4">Edit</button> <button on:click={() => editEnv(env)} class="text-indigo-600 hover:text-indigo-900 mr-4">{$t.common.edit}</button>
<button on:click={() => handleDeleteEnv(env.id)} class="text-red-600 hover:text-red-900">Delete</button> <button on:click={() => handleDeleteEnv(env.id)} class="text-red-600 hover:text-red-900">{$t.settings?.env_delete || "Delete"}</button>
</td> </td>
</tr> </tr>
{/each} {/each}
@@ -202,44 +246,30 @@
</table> </table>
</div> </div>
<div class="bg-gray-50 p-4 rounded"> <div class="mt-8 bg-gray-50 p-6 rounded-lg border border-gray-100">
<h3 class="text-lg font-medium mb-4">{editingEnvId ? 'Edit' : 'Add'} Environment</h3> <h3 class="text-lg font-medium mb-6">{editingEnvId ? ($t.settings?.env_edit || "Edit Environment") : ($t.settings?.env_add || "Add Environment")}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <Input label="ID" bind:value={newEnv.id} disabled={!!editingEnvId} />
<label for="env_id" class="block text-sm font-medium text-gray-700">ID</label> <Input label={$t.connections?.name || "Name"} bind:value={newEnv.name} />
<input type="text" id="env_id" bind:value={newEnv.id} disabled={!!editingEnvId} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> <Input label="URL" bind:value={newEnv.url} />
</div> <Input label={$t.connections?.user || "Username"} bind:value={newEnv.username} />
<div> <Input label={$t.connections?.pass || "Password"} type="password" bind:value={newEnv.password} />
<label for="env_name" class="block text-sm font-medium text-gray-700">Name</label> <div class="flex items-center gap-2 h-10 mt-auto">
<input type="text" id="env_name" bind:value={newEnv.name} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> <input type="checkbox" id="env_default" bind:checked={newEnv.is_default} class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" />
</div> <label for="env_default" class="text-sm font-medium text-gray-700">{$t.settings?.env_default || "Default Environment"}</label>
<div>
<label for="env_url" class="block text-sm font-medium text-gray-700">URL</label>
<input type="text" id="env_url" bind:value={newEnv.url} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
</div>
<div>
<label for="env_user" class="block text-sm font-medium text-gray-700">Username</label>
<input type="text" id="env_user" bind:value={newEnv.username} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
</div>
<div>
<label for="env_pass" class="block text-sm font-medium text-gray-700">Password</label>
<input type="password" id="env_pass" bind:value={newEnv.password} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
</div>
<div class="flex items-center">
<input type="checkbox" id="env_default" bind:checked={newEnv.is_default} class="h-4 w-4 text-blue-600 border-gray-300 rounded" />
<label for="env_default" class="ml-2 block text-sm text-gray-900">Default Environment</label>
</div> </div>
</div> </div>
<div class="mt-4 flex gap-2"> <div class="mt-8 flex gap-3">
<button on:click={handleAddOrUpdateEnv} class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"> <Button on:click={handleAddOrUpdateEnv}>
{editingEnvId ? 'Update' : 'Add'} Environment {editingEnvId ? $t.common.save : ($t.settings?.env_add || "Add Environment")}
</button> </Button>
{#if editingEnvId} {#if editingEnvId}
<button on:click={resetEnvForm} class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"> <Button variant="secondary" on:click={resetEnvForm}>
Cancel {$t.common.cancel}
</button> </Button>
{/if} {/if}
</div> </div>
</div> </div>
</Card>
</section> </section>
</div> </div>

View File

@@ -18,7 +18,6 @@ export async function load() {
settings: { settings: {
environments: [], environments: [],
settings: { settings: {
backup_path: '',
default_environment_id: null default_environment_id: null
} }
}, },

View File

@@ -0,0 +1,182 @@
<!-- [DEF:GitSettingsPage:Component] -->
<!--
@SEMANTICS: git, settings, configuration, integration
@PURPOSE: Manage Git server configurations for dashboard versioning.
@LAYER: Page
@RELATION: USES -> gitService
@RELATION: USES -> Button, Input, Card, PageHeader, Select
@INVARIANT: All configurations must be validated via connection test.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { gitService } from '../../../services/gitService';
import { addToast as toast } from '../../../lib/toasts.js';
import { t } from '$lib/i18n';
import { Button, Input, Card, PageHeader, Select } from '$lib/ui';
// [/SECTION: IMPORTS]
// [SECTION: STATE]
let configs = [];
let newConfig = {
name: '',
provider: 'GITHUB',
url: 'https://github.com',
pat: '',
default_repository: ''
};
let testing = false;
// [/SECTION: STATE]
// [DEF:loadConfigs:Function]
/**
* @purpose Fetches existing git configurations.
* @pre Component is mounted.
* @post configs state is populated.
*/
async function loadConfigs() {
try {
configs = await gitService.getConfigs();
} catch (e) {
toast(e.message, 'error');
}
}
// [/DEF:loadConfigs:Function]
onMount(loadConfigs);
// [DEF:handleTest:Function]
/**
* @purpose Tests connection to a git server with current form data.
* @pre newConfig contains valid provider, url, and pat.
* @post testing state is managed; toast shown with result.
*/
async function handleTest() {
testing = true;
try {
const result = await gitService.testConnection(newConfig);
if (result.status === 'success') {
toast('Connection successful', 'success');
} else {
toast(result.message || 'Connection failed', 'error');
}
} catch (e) {
toast('Connection failed', 'error');
} finally {
testing = false;
}
}
// [/DEF:handleTest:Function]
// [DEF:handleSave:Function]
/**
* @purpose Saves a new git configuration.
* @pre newConfig is valid and tested.
* @post New config is saved to DB and added to configs list.
*/
async function handleSave() {
try {
const saved = await gitService.createConfig(newConfig);
configs = [...configs, saved];
toast('Configuration saved', 'success');
newConfig = { name: '', provider: 'GITHUB', url: 'https://github.com', pat: '', default_repository: '' };
} catch (e) {
toast(e.message, 'error');
}
}
// [/DEF:handleSave:Function]
// [DEF:handleDelete:Function]
/**
* @purpose Deletes a git configuration by ID.
* @param {string} id - Configuration ID.
* @pre id is valid; user confirmed deletion.
* @post Configuration is removed from DB and local state.
*/
async function handleDelete(id) {
if (!confirm('Are you sure you want to delete this Git configuration?')) return;
try {
await gitService.deleteConfig(id);
configs = configs.filter(c => c.id !== id);
toast('Configuration deleted', 'success');
} catch (e) {
toast(e.message, 'error');
}
}
// [/DEF:handleDelete:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="p-6 max-w-6xl mx-auto">
<PageHeader title="Git Integration Settings" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- List of Configs -->
<Card title="Configured Servers">
{#if configs.length === 0}
<p class="text-gray-500">No Git servers configured.</p>
{:else}
<ul class="divide-y divide-gray-100">
{#each configs as config}
<li class="py-4 flex justify-between items-center">
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{config.name}</span>
<span class="text-xs font-mono bg-gray-50 text-gray-500 px-1.5 py-0.5 rounded">{config.provider}</span>
</div>
<div class="text-xs text-gray-400 mt-1">{config.url}</div>
</div>
<div class="flex items-center space-x-4">
<span class="px-2 py-1 text-xs font-medium rounded {config.status === 'CONNECTED' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}">
{config.status}
</span>
<button on:click={() => handleDelete(config.id)} class="text-gray-400 hover:text-red-600 transition-colors" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</button>
</div>
</li>
{/each}
</ul>
{/if}
</Card>
<!-- Add New Config -->
<Card title="Add Git Server">
<div class="space-y-6">
<Input label="Display Name" bind:value={newConfig.name} placeholder="e.g. My GitHub" />
<Select
label="Provider"
bind:value={newConfig.provider}
options={[
{ value: 'GITHUB', label: 'GitHub' },
{ value: 'GITLAB', label: 'GitLab' },
{ value: 'GITEA', label: 'Gitea' }
]}
/>
<Input label="Server URL" bind:value={newConfig.url} />
<Input label="Personal Access Token (PAT)" type="password" bind:value={newConfig.pat} />
<Input label="Default Repository (Optional)" bind:value={newConfig.default_repository} placeholder="org/repo" />
<div class="flex gap-3 pt-2">
<Button variant="secondary" on:click={handleTest} isLoading={testing}>
Test Connection
</Button>
<Button variant="primary" on:click={handleSave}>
Save Configuration
</Button>
</div>
</div>
</Card>
</div>
</div>
<!-- [/SECTION: TEMPLATE] -->
<style>
/* Styles are handled by Tailwind */
</style>
<!-- [/DEF:GitSettingsPage:Component] -->

View File

@@ -1,9 +1,19 @@
<!-- [DEF:TaskManagementPage:Component] -->
<!--
@SEMANTICS: tasks, management, history, logs
@PURPOSE: Page for managing and monitoring tasks.
@LAYER: Page
@RELATION: USES -> TaskList
@RELATION: USES -> TaskLogViewer
-->
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { getTasks, createTask, getEnvironmentsList } from '../../lib/api'; import { getTasks, createTask, getEnvironmentsList } from '../../lib/api';
import { addToast } from '../../lib/toasts'; import { addToast } from '../../lib/toasts';
import TaskList from '../../components/TaskList.svelte'; import TaskList from '../../components/TaskList.svelte';
import TaskLogViewer from '../../components/TaskLogViewer.svelte'; import TaskLogViewer from '../../components/TaskLogViewer.svelte';
import { t } from '$lib/i18n';
import { Button, Card, PageHeader, Select } from '$lib/ui';
let tasks = []; let tasks = [];
let environments = []; let environments = [];
@@ -14,11 +24,13 @@
let selectedEnvId = ''; let selectedEnvId = '';
// [DEF:loadInitialData:Function] // [DEF:loadInitialData:Function]
/* @PURPOSE: Loads tasks and environments on page initialization. /**
@PRE: API must be reachable. * @purpose Loads tasks and environments on page initialization.
@POST: tasks and environments variables are populated. * @pre API must be reachable.
*/ * @post tasks and environments variables are populated.
*/
async function loadInitialData() { async function loadInitialData() {
console.log("[loadInitialData][Action] Loading initial tasks and environments");
try { try {
loading = true; loading = true;
const [tasksData, envsData] = await Promise.all([ const [tasksData, envsData] = await Promise.all([
@@ -27,8 +39,9 @@
]); ]);
tasks = tasksData; tasks = tasksData;
environments = envsData; environments = envsData;
console.log(`[loadInitialData][Coherence:OK] Data loaded context={{'tasks': ${tasks.length}, 'envs': ${environments.length}}}`);
} catch (error) { } catch (error) {
console.error('Failed to load tasks data:', error); console.error(`[loadInitialData][Coherence:Failed] Failed to load tasks data context={{'error': '${error.message}'}}`);
} finally { } finally {
loading = false; loading = false;
} }
@@ -36,10 +49,11 @@
// [/DEF:loadInitialData:Function] // [/DEF:loadInitialData:Function]
// [DEF:refreshTasks:Function] // [DEF:refreshTasks:Function]
/* @PURPOSE: Periodically refreshes the task list. /**
@PRE: API must be reachable. * @purpose Periodically refreshes the task list.
@POST: tasks variable is updated if data is valid. * @pre API must be reachable.
*/ * @post tasks variable is updated if data is valid.
*/
async function refreshTasks() { async function refreshTasks() {
try { try {
const data = await getTasks(); const data = await getTasks();
@@ -48,40 +62,45 @@
tasks = data; tasks = data;
} }
} catch (error) { } catch (error) {
console.error('Failed to refresh tasks:', error); console.error(`[refreshTasks][Coherence:Failed] Failed to refresh tasks context={{'error': '${error.message}'}}`);
} }
} }
// [/DEF:refreshTasks:Function] // [/DEF:refreshTasks:Function]
// [DEF:handleSelectTask:Function] // [DEF:handleSelectTask:Function]
/* @PURPOSE: Updates the selected task ID when a task is clicked. /**
@PRE: event.detail.id must be provided. * @purpose Updates the selected task ID when a task is clicked.
@POST: selectedTaskId is updated. * @pre event.detail.id must be provided.
*/ * @post selectedTaskId is updated.
*/
function handleSelectTask(event) { function handleSelectTask(event) {
selectedTaskId = event.detail.id; selectedTaskId = event.detail.id;
console.log(`[handleSelectTask][Action] Task selected context={{'taskId': '${selectedTaskId}'}}`);
} }
// [/DEF:handleSelectTask:Function] // [/DEF:handleSelectTask:Function]
// [DEF:handleRunBackup:Function] // [DEF:handleRunBackup:Function]
/* @PURPOSE: Triggers a manual backup task for the selected environment. /**
@PRE: selectedEnvId must not be empty. * @purpose Triggers a manual backup task for the selected environment.
@POST: Backup task is created and task list is refreshed. * @pre selectedEnvId must not be empty.
*/ * @post Backup task is created and task list is refreshed.
*/
async function handleRunBackup() { async function handleRunBackup() {
if (!selectedEnvId) { if (!selectedEnvId) {
addToast('Please select an environment', 'error'); addToast('Please select an environment', 'error');
return; return;
} }
console.log(`[handleRunBackup][Action] Starting backup for env context={{'envId': '${selectedEnvId}'}}`);
try { try {
const task = await createTask('superset-backup', { environment_id: selectedEnvId }); const task = await createTask('superset-backup', { environment_id: selectedEnvId });
addToast('Backup task started', 'success'); addToast('Backup task started', 'success');
showBackupModal = false; showBackupModal = false;
selectedTaskId = task.id; selectedTaskId = task.id;
await refreshTasks(); await refreshTasks();
console.log(`[handleRunBackup][Coherence:OK] Backup task created context={{'taskId': '${task.id}'}}`);
} catch (error) { } catch (error) {
console.error('Failed to start backup:', error); console.error(`[handleRunBackup][Coherence:Failed] Failed to start backup context={{'error': '${error.message}'}}`);
} }
} }
// [/DEF:handleRunBackup:Function] // [/DEF:handleRunBackup:Function]
@@ -97,31 +116,29 @@
</script> </script>
<div class="container mx-auto p-4 max-w-6xl"> <div class="container mx-auto p-4 max-w-6xl">
<div class="flex justify-between items-center mb-6"> <PageHeader title={$t.tasks.management} />
<h1 class="text-2xl font-bold text-gray-800">Task Management</h1>
<button
on:click={() => showBackupModal = true}
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md shadow-sm transition duration-150 font-medium"
>
Run Backup
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<h2 class="text-lg font-semibold mb-3 text-gray-700">Recent Tasks</h2> <h2 class="text-lg font-semibold mb-3 text-gray-700">{$t.tasks.recent}</h2>
<TaskList {tasks} {loading} on:select={handleSelectTask} /> <TaskList {tasks} {loading} on:select={handleSelectTask} />
</div> </div>
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<h2 class="text-lg font-semibold mb-3 text-gray-700">Task Details & Logs</h2> <h2 class="text-lg font-semibold mb-3 text-gray-700">{$t.tasks.details_logs}</h2>
{#if selectedTaskId} {#if selectedTaskId}
<div class="bg-white rounded-lg shadow-lg h-[600px] flex flex-col"> <Card padding="none">
<TaskLogViewer taskId={selectedTaskId} /> <div class="h-[600px] flex flex-col overflow-hidden rounded-lg">
</div> <TaskLogViewer
taskId={selectedTaskId}
taskStatus={tasks.find(t => t.id === selectedTaskId)?.status}
inline={true}
/>
</div>
</Card>
{:else} {:else}
<div class="bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg h-[600px] flex items-center justify-center text-gray-500"> <div class="bg-gray-50 border-2 border-dashed border-gray-100 rounded-lg h-[600px] flex items-center justify-center text-gray-400">
<p>Select a task to view logs and details</p> <p>{$t.tasks.select_task}</p>
</div> </div>
{/if} {/if}
</div> </div>
@@ -129,36 +146,31 @@
</div> </div>
{#if showBackupModal} {#if showBackupModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm p-4">
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md"> <div class="w-full max-w-md">
<h3 class="text-xl font-bold mb-4">Run Manual Backup</h3> <Card title={$t.tasks.manual_backup}>
<div class="mb-4"> <div class="space-y-6">
<label for="env-select" class="block text-sm font-medium text-gray-700 mb-1">Target Environment</label> <Select
<select label={$t.tasks.target_env}
id="env-select" bind:value={selectedEnvId}
bind:value={selectedEnvId} options={[
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2 border" { value: '', label: $t.tasks.select_env },
> ...environments.map(e => ({ value: e.id, label: e.name }))
<option value="" disabled>-- Select Environment --</option> ]}
{#each environments as env} />
<option value={env.id}>{env.name}</option>
{/each} <div class="flex justify-end gap-3 pt-2">
</select> <Button variant="secondary" on:click={() => showBackupModal = false}>
</div> {$t.common.cancel}
<div class="flex justify-end space-x-3"> </Button>
<button <Button variant="primary" on:click={handleRunBackup}>
on:click={() => showBackupModal = false} Start Backup
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition" </Button>
> </div>
Cancel </div>
</button> </Card>
<button
on:click={handleRunBackup}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition"
>
Start Backup
</button>
</div>
</div> </div>
</div> </div>
{/if} {/if}
<!-- [/DEF:TaskManagementPage:Component] -->

View File

@@ -0,0 +1,27 @@
<!-- [DEF:BackupPage:Component] -->
<!--
@SEMANTICS: backup, page, tools
@PURPOSE: Entry point for the Backup Management interface.
@LAYER: Page
@RELATION: USES -> BackupManager
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { t } from '../../../lib/i18n';
import { PageHeader } from '../../../lib/ui';
import BackupManager from '../../../components/backups/BackupManager.svelte';
// [/SECTION]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4 max-w-6xl">
<PageHeader title={$t.nav.tools_backups} />
<div class="mt-6">
<BackupManager />
</div>
</div>
<!-- [/SECTION] -->
<!-- [/DEF:BackupPage:Component] -->

View File

@@ -7,19 +7,18 @@
<script> <script>
import DebugTool from '../../../components/tools/DebugTool.svelte'; import DebugTool from '../../../components/tools/DebugTool.svelte';
import TaskRunner from '../../../components/TaskRunner.svelte'; import TaskRunner from '../../../components/TaskRunner.svelte';
import { PageHeader } from '$lib/ui';
</script> </script>
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto p-6">
<div class="px-4 py-6 sm:px-0"> <PageHeader title="System Diagnostics" />
<h1 class="text-2xl font-semibold text-gray-900 mb-6">System Diagnostics</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div class="lg:col-span-2">
<div class="lg:col-span-2"> <DebugTool />
<DebugTool /> </div>
</div> <div class="lg:col-span-1">
<div class="lg:col-span-1"> <TaskRunner />
<TaskRunner />
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,19 +7,18 @@
<script> <script>
import MapperTool from '../../../components/tools/MapperTool.svelte'; import MapperTool from '../../../components/tools/MapperTool.svelte';
import TaskRunner from '../../../components/TaskRunner.svelte'; import TaskRunner from '../../../components/TaskRunner.svelte';
import { PageHeader } from '$lib/ui';
</script> </script>
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto p-6">
<div class="px-4 py-6 sm:px-0"> <PageHeader title="Dataset Column Mapper" />
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Dataset Column Mapper</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div class="lg:col-span-2">
<div class="lg:col-span-2"> <MapperTool />
<MapperTool /> </div>
</div> <div class="lg:col-span-1">
<div class="lg:col-span-1"> <TaskRunner />
<TaskRunner />
</div>
</div> </div>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More