Compare commits
22 Commits
ec8d67c956
...
015-fronte
| Author | SHA1 | Date | |
|---|---|---|---|
| d10c23e658 | |||
| 1042b35d1b | |||
| 16ffeb1ed6 | |||
| da34deac02 | |||
| 51e9ee3fcc | |||
| edf9286071 | |||
| a542e7d2df | |||
| a863807cf2 | |||
| e2bc68683f | |||
| 43cb82697b | |||
| 4ba28cf93e | |||
| 343f2e29f5 | |||
| c9a53578fd | |||
| 07ec2d9797 | |||
| e9d3f3c827 | |||
| 26ba015b75 | |||
| 49129d3e86 | |||
| d99a13d91f | |||
| 203ce446f4 | |||
| c96d50a3f4 | |||
| 3bbe320949 | |||
| 2d2435642d |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"mcpServers":{"tavily":{"command":"npx","args":["-y","tavily-mcp@0.2.3"],"env":{"TAVILY_API_KEY":"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"},"alwaysAllow":[]}}}
|
{"mcpServers":{}}
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1
backend/backend/git_repos/12
Submodule
1
backend/backend/git_repos/12
Submodule
Submodule backend/backend/git_repos/12 added at f46772443a
@@ -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.
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
44
backend/delete_running_tasks.py
Normal file
44
backend/delete_running_tasks.py
Normal 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
79101
backend/logs/app.log.1
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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
|
||||||
@@ -1 +1 @@
|
|||||||
from . import plugins, tasks, settings, connections
|
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|||||||
337
backend/src/api/routes/git.py
Normal file
337
backend/src/api/routes/git.py
Normal 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]
|
||||||
143
backend/src/api/routes/git_schemas.py
Normal file
143
backend/src/api/routes/git_schemas.py
Normal 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]
|
||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
132
backend/src/api/routes/storage.py
Normal file
132
backend/src/api/routes/storage.py
Normal 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]
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
477
superset_tool/utils/dataset_mapper.py → backend/src/core/utils/dataset_mapper.py
Executable file → Normal file
477
superset_tool/utils/dataset_mapper.py → backend/src/core/utils/dataset_mapper.py
Executable file → Normal 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]
|
|
||||||
995
superset_tool/utils/fileio.py → backend/src/core/utils/fileio.py
Executable file → Normal file
995
superset_tool/utils/fileio.py → backend/src/core/utils/fileio.py
Executable file → Normal 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]
|
|
||||||
591
superset_tool/utils/network.py → backend/src/core/utils/network.py
Executable file → Normal file
591
superset_tool/utils/network.py → backend/src/core/utils/network.py
Executable file → Normal 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
73
backend/src/models/git.py
Normal 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]
|
||||||
31
backend/src/models/storage.py
Normal file
31
backend/src/models/storage.py
Normal 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]
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
385
backend/src/plugins/git_plugin.py
Normal file
385
backend/src/plugins/git_plugin.py
Normal 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]
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
3
backend/src/plugins/storage/__init__.py
Normal file
3
backend/src/plugins/storage/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .plugin import StoragePlugin
|
||||||
|
|
||||||
|
__all__ = ["StoragePlugin"]
|
||||||
333
backend/src/plugins/storage/plugin.py
Normal file
333
backend/src/plugins/storage/plugin.py
Normal 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]
|
||||||
413
backend/src/services/git_service.py
Normal file
413
backend/src/services/git_service.py
Normal 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]
|
||||||
@@ -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]
|
||||||
|
|||||||
BIN
backend/tasks.db
BIN
backend/tasks.db
Binary file not shown.
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version: 1.0.0
|
||||||
|
type: Dashboard
|
||||||
|
timestamp: '2026-01-14T11:21:08.078620+00:00'
|
||||||
@@ -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
7
frontend/.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
*.min.js
|
||||||
26
frontend/.gitignore
vendored
Executable file
26
frontend/.gitignore
vendored
Executable 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
9
frontend/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -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>
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
|
|||||||
@@ -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] -->
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -57,4 +57,4 @@
|
|||||||
/* Component specific styles */
|
/* Component specific styles */
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- [/DEF:EnvSelector:Component] -->
|
<!-- [/DEF:EnvSelector:Component] -->
|
||||||
@@ -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] -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">​</span>
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</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] -->
|
||||||
84
frontend/src/components/backups/BackupList.svelte
Normal file
84
frontend/src/components/backups/BackupList.svelte
Normal 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] -->
|
||||||
241
frontend/src/components/backups/BackupManager.svelte
Normal file
241
frontend/src/components/backups/BackupManager.svelte
Normal 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] -->
|
||||||
178
frontend/src/components/git/BranchSelector.svelte
Normal file
178
frontend/src/components/git/BranchSelector.svelte
Normal 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] -->
|
||||||
95
frontend/src/components/git/CommitHistory.svelte
Normal file
95
frontend/src/components/git/CommitHistory.svelte
Normal 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] -->
|
||||||
175
frontend/src/components/git/CommitModal.svelte
Normal file
175
frontend/src/components/git/CommitModal.svelte
Normal 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] -->
|
||||||
142
frontend/src/components/git/ConflictResolver.svelte
Normal file
142
frontend/src/components/git/ConflictResolver.svelte
Normal 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] -->
|
||||||
148
frontend/src/components/git/DeploymentModal.svelte
Normal file
148
frontend/src/components/git/DeploymentModal.svelte
Normal 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] -->
|
||||||
300
frontend/src/components/git/GitManager.svelte
Normal file
300
frontend/src/components/git/GitManager.svelte
Normal 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] -->
|
||||||
134
frontend/src/components/storage/FileList.svelte
Normal file
134
frontend/src/components/storage/FileList.svelte
Normal 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] -->
|
||||||
134
frontend/src/components/storage/FileUpload.svelte
Normal file
134
frontend/src/components/storage/FileUpload.svelte
Normal 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] -->
|
||||||
@@ -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] -->
|
||||||
@@ -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] -->
|
||||||
@@ -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] -->
|
||||||
@@ -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] -->
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
96
frontend/src/routes/git/+page.svelte
Normal file
96
frontend/src/routes/git/+page.svelte
Normal 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] -->
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export async function load() {
|
|||||||
settings: {
|
settings: {
|
||||||
environments: [],
|
environments: [],
|
||||||
settings: {
|
settings: {
|
||||||
backup_path: '',
|
|
||||||
default_environment_id: null
|
default_environment_id: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
182
frontend/src/routes/settings/git/+page.svelte
Normal file
182
frontend/src/routes/settings/git/+page.svelte
Normal 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] -->
|
||||||
@@ -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] -->
|
||||||
27
frontend/src/routes/tools/backups/+page.svelte
Normal file
27
frontend/src/routes/tools/backups/+page.svelte
Normal 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] -->
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user