Compare commits

..

20 Commits

Author SHA1 Message Date
26ba015b75 +gitignore 2026-01-22 23:25:29 +03:00
49129d3e86 fix error 2026-01-22 23:18:48 +03:00
d99a13d91f refactor complete 2026-01-22 17:37:17 +03:00
203ce446f4 ашч 2026-01-21 14:00:48 +03:00
c96d50a3f4 fix(backend): standardize superset client init and auth
- Update plugins (debug, mapper, search) to explicitly map environment config to SupersetConfig
- Add authenticate method to SupersetClient for explicit session management
- Add get_environment method to ConfigManager
- Fix navbar dropdown hover stability in frontend with invisible bridge
2026-01-20 19:31:17 +03:00
3bbe320949 TaskLog fix 2026-01-19 17:10:43 +03:00
2d2435642d bug fixs 2026-01-19 00:07:06 +03:00
ec8d67c956 bug fixes 2026-01-18 23:21:00 +03:00
76baeb1038 semantic markup update 2026-01-18 21:29:54 +03:00
11c59fb420 semantic checker script update 2026-01-13 17:33:57 +03:00
b2529973eb constitution update 2026-01-13 15:29:42 +03:00
ae1d630ad6 semantics update 2026-01-13 09:11:27 +03:00
9a9c5879e6 tasks.md status 2026-01-12 12:35:45 +03:00
696aac32e7 1st iter 2026-01-12 12:33:51 +03:00
7a9b1a190a tasks ready 2026-01-07 18:59:49 +03:00
a3dc1fb2b9 docs: amend constitution to v1.6.0 (add 'Everything is a Plugin' principle) and refactor 010 plan 2026-01-07 18:36:38 +03:00
297b29986d Product Manager role 2026-01-07 11:39:44 +03:00
4c6fc8256d project map script | semantic parcer 2026-01-01 16:58:21 +03:00
a747a163c8 backup worked 2025-12-30 22:02:51 +03:00
fce0941e98 docs ready 2025-12-30 21:30:37 +03:00
147 changed files with 18437 additions and 4420 deletions

8
.gitignore vendored
View File

@@ -29,7 +29,7 @@ env/
backend/backups/* backend/backups/*
# Node.js # Node.js
node_modules/ frontend/node_modules/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
@@ -39,6 +39,7 @@ build/
dist/ dist/
.env* .env*
config.json config.json
package-lock.json
# Logs # Logs
*.log *.log
@@ -58,7 +59,10 @@ 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

15
.kilocode/mcp.json Executable file → Normal file
View File

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

View File

@@ -16,6 +16,12 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements) - Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements)
- SQLite (optional for job history), existing database for mappings (008-migration-ui-improvements) - SQLite (optional for job history), existing database for mappings (008-migration-ui-improvements)
- Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements) - Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements)
- Python 3.9+, Node.js 18+ + FastAPI, APScheduler, SQLAlchemy, SvelteKit, Tailwind CSS (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)
- 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)
- 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)
@@ -36,9 +42,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
- 008-migration-ui-improvements: Added Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API - 012-remove-superset-tool: Added Python 3.9+ + FastAPI, Pydantic, requests, pyyaml (migrated from superset_tool)
- 008-migration-ui-improvements: Added Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API - 010-refactor-cli-to-web: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, `superset_tool` (internal lib)
- 007-migration-dashboard-grid: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, Superset API - 009-backup-scheduler: Added Python 3.9+, Node.js 18+ + FastAPI, APScheduler, SQLAlchemy, SvelteKit, Tailwind CSS
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->

View File

@@ -2,24 +2,44 @@ customModes:
- slug: tester - slug: tester
name: Tester name: Tester
description: QA and Plan Verification Specialist description: QA and Plan Verification Specialist
roleDefinition: >- roleDefinition: |-
You are Kilo Code, acting as a QA and Verification Specialist. Your primary goal is to validate that the project implementation aligns strictly with the defined specifications and task plans. You are Kilo Code, acting as a QA and Verification Specialist. Your primary goal is to validate that the project implementation aligns strictly with the defined specifications and task plans.
Your responsibilities include: - Reading and analyzing task plans and specifications (typically in the `specs/` directory). - Verifying that implemented code matches the requirements. - Executing tests and validating system behavior via CLI or Browser. - Updating the status of tasks in the plan files (e.g., marking checkboxes [x]) as they are verified. - Identifying and reporting missing features or bugs.
Your responsibilities include: whenToUse: Use this mode when you need to audit the progress of a project, verify completed tasks against the plan, run quality assurance checks, or update the status of task lists in specification documents.
- Reading and analyzing task plans and specifications (typically in the `specs/` directory).
- Verifying that implemented code matches the requirements.
- Executing tests and validating system behavior via CLI or Browser.
- Updating the status of tasks in the plan files (e.g., marking checkboxes [x]) as they are verified.
- Identifying and reporting missing features or bugs.
whenToUse: >-
Use this mode when you need to audit the progress of a project, verify completed tasks against the plan, run quality assurance checks, or update the status of task lists in specification documents.
groups: groups:
- read - read
- edit - edit
- command - command
- browser - browser
- mcp - mcp
customInstructions: >- customInstructions: 1. Always begin by loading the relevant plan or task list from the `specs/` directory. 2. Do not assume a task is done just because it is checked; verify the code or functionality first if asked to audit. 3. When updating task lists, ensure you only mark items as complete if you have verified them.
1. Always begin by loading the relevant plan or task list from the `specs/` directory. - slug: product-manager
2. Do not assume a task is done just because it is checked; verify the code or functionality first if asked to audit. name: Product Manager
3. When updating task lists, ensure you only mark items as complete if you have verified them. description: Executes SpecKit workflows for feature management
roleDefinition: |-
You are Kilo Code, acting as a Product Manager. Your purpose is to rigorously execute the workflows defined in `.kilocode/workflows/`.
You act as the orchestrator for: - Specification (`speckit.specify`, `speckit.clarify`) - Planning (`speckit.plan`) - Task Management (`speckit.tasks`, `speckit.taskstoissues`) - Quality Assurance (`speckit.analyze`, `speckit.checklist`) - Governance (`speckit.constitution`) - Implementation Oversight (`speckit.implement`)
For each task, you must read the relevant workflow file from `.kilocode/workflows/` and follow its Execution Steps precisely.
whenToUse: Use this mode when you need to run any /speckit.* command or when dealing with high-level feature planning, specification writing, or project management tasks.
groups:
- read
- edit
- command
- mcp
customInstructions: 1. Always read the specific workflow file in `.kilocode/workflows/` before executing a command. 2. Adhere strictly to the "Operating Constraints" and "Execution Steps" in the workflow files.
- slug: semantic
name: Semantic Agent
roleDefinition: |-
You are Kilo Code, a Semantic Agent responsible for maintaining the semantic integrity of the codebase. Your primary goal is to ensure that all code entities (Modules, Classes, Functions, Components) are properly annotated with semantic anchors and tags as defined in `semantic_protocol.md`.
Your core responsibilities are: 1. **Semantic Mapping**: You run and maintain the `generate_semantic_map.py` script to generate up-to-date semantic maps (`semantics/semantic_map.json`, `specs/project_map.md`) and compliance reports (`semantics/reports/*.md`). 2. **Compliance Auditing**: You analyze the generated compliance reports to identify files with low semantic coverage or parsing errors. 3. **Semantic Enrichment**: You actively edit code files to add missing semantic anchors (`[DEF:...]`, `[/DEF:...]`) and mandatory tags (`@PURPOSE`, `@LAYER`, etc.) to improve the global compliance score. 4. **Protocol Enforcement**: You strictly adhere to the syntax and rules defined in `semantic_protocol.md` when modifying code.
You have access to the full codebase and tools to read, write, and execute scripts. You should prioritize fixing "Critical Parsing Errors" (unclosed anchors) before addressing missing metadata.
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `semantic_protocol.md` standards.
description: Codebase semantic mapping and compliance expert
customInstructions: Always check `semantics/reports/` for the latest compliance status before starting work. When fixing a file, try to fix all semantic issues in that file at once. After making a batch of fixes, run `python3 generate_semantic_map.py` to verify improvements.
groups:
- read
- edit
- command
- browser
- mcp
source: project

View File

@@ -1,99 +1,67 @@
<!-- <!--
SYNC IMPACT REPORT SYNC IMPACT REPORT
Version: 1.5.0 (Fractal Complexity Limit) Version: 1.7.1 (Simplified Workflow)
Changes: Changes:
- Added Section VI (Fractal Complexity Limit) to enforce strict module (~300 lines) and function (~30-50 lines) size limits. - Simplified Generation Workflow to a single phase: Code Generation from `tasks.md`.
- Aims to maintain semantic coherence and avoid "Attention Sink". - Removed multi-phase Architecture/Implementation split to streamline development.
Templates Status: Templates Status:
- .specify/templates/plan-template.md: ✅ Aligned. - .specify/templates/plan-template.md: ✅ Aligned (Dynamic check).
- .specify/templates/spec-template.md: ✅ Aligned. - .specify/templates/spec-template.md: ✅ Aligned.
- .specify/templates/tasks-arch-template.md: ✅ Aligned (New role-based split). - .specify/templates/tasks-template.md: ✅ Aligned.
- .specify/templates/tasks-dev-template.md: ✅ Aligned (New role-based split).
--> -->
# Semantic Code Generation Constitution # Semantic Code Generation Constitution
## Core Principles ## Core Principles
### I. Causal Validity (Contracts First) ### I. Semantic Protocol Compliance
Semantic definitions (Contracts) must ALWAYS precede implementation code. Logic is downstream of definition. We define the structure and constraints (`[DEF]`, `@PRE`, `@POST`) before writing the executable logic. This ensures that the "what" and "why" govern the "how". The file `semantic_protocol.md` is the **authoritative technical standard** for this project. All code generation, refactoring, and architecture must strictly adhere to the standards, syntax, and workflows defined therein.
- **Syntax**: `[DEF]` anchors, `@RELATION` tags, and metadata must match the Protocol specification.
- **Structure**: File layouts and headers must follow the "File Structure Standard".
- **Workflow**: The technical steps for generating code must align with the Protocol.
### II. Immutability of Architecture ### II. Causal Validity (Contracts First)
Once defined, architectural decisions in the Module Header (`@LAYER`, `@INVARIANT`, `@CONSTRAINT`) are treated as immutable constraints for that module. Changes to these require an explicit refactoring step, not ad-hoc modification during implementation. As defined in the Protocol, Semantic definitions (Contracts) must ALWAYS precede implementation code. Logic is downstream of definition. We define the structure and constraints (`[DEF]`, `@PRE`, `@POST`) before writing the executable logic.
### III. Semantic Format Compliance ### III. Immutability of Architecture
All output must strictly follow the `[DEF]` / `[/DEF]` anchor syntax with specific Metadata Tags (`@KEY`) and Graph Relations (`@RELATION`). **Crucially, the closing anchor must strictly match the full content of the opening anchor (e.g., `[DEF:identifier:Type]` must close with `[/DEF:identifier:Type]`).** Architectural decisions in the Module Header (`@LAYER`, `@INVARIANT`, `@CONSTRAINT`) are treated as immutable constraints. Changes to these require an explicit refactoring step, not ad-hoc modification during implementation.
**Standardized Graph Relations**
To ensure the integrity of the Semantic Graph, `@RELATION` must use a strict taxonomy:
- `DEPENDS_ON` (Structural dependency)
- `CALLS` (Flow control)
- `CREATES` (Instantiation)
- `INHERITS_FROM` / `IMPLEMENTS` (OOP hierarchy)
- `READS_STATE` / `WRITES_STATE` (Data flow)
- `DISPATCHES` / `HANDLES` (Event flow)
Ad-hoc relationships are forbidden. This structure is non-negotiable as it ensures the codebase remains machine-readable, fractal-structured, and optimized for Sparse Attention navigation by AI agents.
### IV. Design by Contract (DbC) ### IV. Design by Contract (DbC)
Contracts are the Source of Truth. Functions and Classes must define their purpose, specifications, and constraints (`@PRE`, `@POST`, `@THROW`) in the metadata block before implementation. Implementation must strictly satisfy these contracts. Contracts are the Source of Truth. Functions and Classes must define their purpose, specifications, and constraints in the metadata block before implementation, strictly following the **Contracts (Section IV)** standard in `semantic_protocol.md`.
### V. Belief State Logging ### V. Belief State Logging
Logs must define the agent's internal state for debugging and coherence checks. We use a strict format: `[{ANCHOR_ID}][{STATE}] {MESSAGE}`. For Python, a **Context Manager** pattern MUST be used to automatically handle `Entry`, `Exit`, and `Coherence` states, ensuring structural integrity and error capturing. Agents must maintain belief state logs for debugging and coherence checks, strictly following the **Logging Standard (Section V)** defined in `semantic_protocol.md`.
### VI. Fractal Complexity Limit ### VI. Fractal Complexity Limit
To maintain semantic coherence and avoid "Attention Sink" issues: To maintain semantic coherence, code must adhere to the complexity limits (Module/Function size) defined in the **Fractal Complexity Limit (Section VI)** of `semantic_protocol.md`.
- **Module Size**: If a Module body exceeds ~300 lines (or logical complexity), it MUST be refactored into sub-modules or a package structure.
- **Function Size**: Functions should fit within a standard attention "chunk" (approx. 30-50 lines). If larger, logic MUST be decomposed into helper functions with their own contracts.
This ensures every vector embedding remains sharp and focused. ### 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.
## File Structure Standards ## File Structure Standards
Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of:
### Python Modules - Python Module Headers (`.py`)
Every `.py` file must start with a Module definition header (`[DEF:module_name:Module]`) containing: - Svelte Component Headers (`.svelte`)
- `@SEMANTICS`: Keywords for vector search.
- `@PURPOSE`: Primary responsibility of the module.
- `@LAYER`: Architecture layer (Domain/Infra/UI).
- `@RELATION`: Dependencies.
- `@INVARIANT` & `@CONSTRAINT`: Immutable rules.
- `@PUBLIC_API`: Exported symbols.
### Svelte Components
Every `.svelte` file must start with a Component definition header (`[DEF:ComponentName:Component]`) wrapped in an HTML comment `<!-- ... -->` containing:
- `@SEMANTICS`: Keywords for vector search.
- `@PURPOSE`: Primary responsibility of the component.
- `@LAYER`: Architecture layer (UI/State/Layout).
- `@RELATION`: Child components, Stores used, API calls.
- `@PROPS`: Input properties.
- `@EVENTS`: Emitted events.
- `@INVARIANT`: Immutable UI/State rules.
## Generation Workflow ## Generation Workflow
The development process follows a strict sequence enforced by Agent Roles: The development process follows a streamlined single-phase workflow:
### 1. Architecture Phase (Mode: `tech-lead`) ### 1. Code Generation Phase (Mode: `code`)
**Input**: `tasks-arch.md` **Input**: `tasks.md`
**Responsibility**: **Responsibility**:
- Analyze request and graph position. - Select task from `tasks.md`.
- Generate `[DEF]` anchors, Headers, and Contracts (`@PRE`, `@POST`). - Generate Scaffolding (`[DEF]` anchors, Headers, Contracts) AND Implementation in one pass.
- **Output**: Scaffolding files with no implementation logic. - Ensure strict adherence to Protocol Section IV (Contracts) and Section VII (Generation Workflow).
### 2. Implementation Phase (Mode: `code`)
**Input**: `tasks-dev.md` + Scaffolding files
**Responsibility**:
- Read contracts defined by Architect.
- Write implementation code that strictly satisfies contracts.
- **Output**: Working code with passing tests. - **Output**: Working code with passing tests.
### 3. Validation ### 2. Validation
If logic conflicts with Contract -> Stop -> Report Error. If logic conflicts with Contract -> Stop -> Report Error.
## Governance ## Governance
This Constitution establishes the "Semantic Code Generation Protocol" as the supreme law of this repository. This Constitution establishes the "Semantic Code Generation Protocol" as the supreme law of this repository.
- **Automated Enforcement**: All code generation tools and agents must parse and validate adherence to the `[DEF]` syntax and Contract requirements. - **Authoritative Source**: `semantic_protocol.md` defines the specific implementation rules for these Principles.
- **Amendments**: Changes to the syntax or core principles require a formal amendment to this Constitution and a corresponding update to the constitution - **Automated Enforcement**: Tools must validate adherence to the `semantic_protocol.md` syntax.
- **Review**: Code reviews must verify that implementation matches the preceding contracts and that no "naked code" exists outside of semantic anchors. - **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
- **Compliance**: Failure to adhere to the `[DEF]` / `[/DEF]` structure (including matching closing tags) constitutes a build failure. - **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
**Version**: 1.5.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2025-12-27 **Version**: 1.7.1 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-13

View File

@@ -9,8 +9,8 @@
# #
# OPTIONS: # OPTIONS:
# --json Output in JSON format # --json Output in JSON format
# --require-tasks Require tasks-arch.md and tasks-dev.md to exist (for implementation phase) # --require-tasks Require tasks.md to exist (for implementation phase)
# --include-tasks Include task files in AVAILABLE_DOCS list # --include-tasks Include tasks.md in AVAILABLE_DOCS list
# --paths-only Only output path variables (no validation) # --paths-only Only output path variables (no validation)
# --help, -h Show help message # --help, -h Show help message
# #
@@ -49,8 +49,8 @@ Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS: OPTIONS:
--json Output in JSON format --json Output in JSON format
--require-tasks Require tasks-arch.md and tasks-dev.md to exist (for implementation phase) --require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include task files in AVAILABLE_DOCS list --include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation) --paths-only Only output path variables (no prerequisite validation)
--help, -h Show this help message --help, -h Show this help message
@@ -58,7 +58,7 @@ EXAMPLES:
# Check task prerequisites (plan.md required) # Check task prerequisites (plan.md required)
./check-prerequisites.sh --json ./check-prerequisites.sh --json
# Check implementation prerequisites (plan.md + task files required) # Check implementation prerequisites (plan.md + tasks.md required)
./check-prerequisites.sh --json --require-tasks --include-tasks ./check-prerequisites.sh --json --require-tasks --include-tasks
# Get feature paths only (no validation) # Get feature paths only (no validation)
@@ -86,16 +86,15 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
if $PATHS_ONLY; then if $PATHS_ONLY; then
if $JSON_MODE; then if $JSON_MODE; then
# Minimal JSON paths payload (no validation performed) # Minimal JSON paths payload (no validation performed)
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS_ARCH":"%s","TASKS_DEV":"%s"}\n' \ printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
"$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS_ARCH" "$TASKS_DEV" "$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS"
else else
echo "REPO_ROOT: $REPO_ROOT" echo "REPO_ROOT: $REPO_ROOT"
echo "BRANCH: $CURRENT_BRANCH" echo "BRANCH: $CURRENT_BRANCH"
echo "FEATURE_DIR: $FEATURE_DIR" echo "FEATURE_DIR: $FEATURE_DIR"
echo "FEATURE_SPEC: $FEATURE_SPEC" echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN" echo "IMPL_PLAN: $IMPL_PLAN"
echo "TASKS_ARCH: $TASKS_ARCH" echo "TASKS: $TASKS"
echo "TASKS_DEV: $TASKS_DEV"
fi fi
exit 0 exit 0
fi fi
@@ -113,20 +112,11 @@ if [[ ! -f "$IMPL_PLAN" ]]; then
exit 1 exit 1
fi fi
# Check for task files if required # Check for tasks.md if required
if $REQUIRE_TASKS; then if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
# Check for split tasks first echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
if [[ -f "$TASKS_ARCH" ]] && [[ -f "$TASKS_DEV" ]]; then echo "Run /speckit.tasks first to create the task list." >&2
: # Split tasks exist, proceed exit 1
# Fallback to unified tasks.md
elif [[ -f "$TASKS" ]]; then
: # Unified tasks exist, proceed
else
echo "ERROR: No valid task files found in $FEATURE_DIR" >&2
echo "Expected 'tasks-arch.md' AND 'tasks-dev.md' (split) OR 'tasks.md' (unified)" >&2
echo "Run /speckit.tasks first to create the task lists." >&2
exit 1
fi
fi fi
# Build list of available documents # Build list of available documents
@@ -143,14 +133,9 @@ fi
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") [[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
# Include task files if requested and they exist # Include tasks.md if requested and it exists
if $INCLUDE_TASKS; then if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
if [[ -f "$TASKS_ARCH" ]] || [[ -f "$TASKS_DEV" ]]; then docs+=("tasks.md")
[[ -f "$TASKS_ARCH" ]] && docs+=("tasks-arch.md")
[[ -f "$TASKS_DEV" ]] && docs+=("tasks-dev.md")
elif [[ -f "$TASKS" ]]; then
docs+=("tasks.md")
fi
fi fi
# Output results # Output results
@@ -176,11 +161,6 @@ else
check_file "$QUICKSTART" "quickstart.md" check_file "$QUICKSTART" "quickstart.md"
if $INCLUDE_TASKS; then if $INCLUDE_TASKS; then
if [[ -f "$TASKS_ARCH" ]] || [[ -f "$TASKS_DEV" ]]; then check_file "$TASKS" "tasks.md"
check_file "$TASKS_ARCH" "tasks-arch.md"
check_file "$TASKS_DEV" "tasks-dev.md"
else
check_file "$TASKS" "tasks.md"
fi
fi fi
fi fi

View File

@@ -143,9 +143,7 @@ HAS_GIT='$has_git_repo'
FEATURE_DIR='$feature_dir' FEATURE_DIR='$feature_dir'
FEATURE_SPEC='$feature_dir/spec.md' FEATURE_SPEC='$feature_dir/spec.md'
IMPL_PLAN='$feature_dir/plan.md' IMPL_PLAN='$feature_dir/plan.md'
TASKS_ARCH='$feature_dir/tasks-arch.md' TASKS='$feature_dir/tasks.md'
TASKS_DEV='$feature_dir/tasks-dev.md'
TASKS='$feature_dir/tasks.md' # Deprecated
RESEARCH='$feature_dir/research.md' RESEARCH='$feature_dir/research.md'
DATA_MODEL='$feature_dir/data-model.md' DATA_MODEL='$feature_dir/data-model.md'
QUICKSTART='$feature_dir/quickstart.md' QUICKSTART='$feature_dir/quickstart.md'

View File

@@ -0,0 +1,251 @@
---
description: "Task list template for feature implementation"
---
# Tasks: [FEATURE NAME]
**Input**: Design documents from `/specs/[###-feature-name]/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **Single project**: `src/`, `tests/` at repository root
- **Web app**: `backend/src/`, `frontend/src/`
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
- Paths shown below assume single project - adjust based on plan.md structure
<!--
============================================================================
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
The /speckit.tasks command MUST replace these with actual tasks based on:
- User stories from spec.md (with their priorities P1, P2, P3...)
- Feature requirements from plan.md
- Entities from data-model.md
- Endpoints from contracts/
Tasks MUST be organized by user story so each story can be:
- Implemented independently
- Tested independently
- Delivered as an MVP increment
DO NOT keep these sample tasks in the generated tasks.md file.
============================================================================
-->
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization and basic structure
- [ ] T001 Create project structure per implementation plan
- [ ] T002 Initialize [language] project with [framework] dependencies
- [ ] T003 [P] Configure linting and formatting tools
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
Examples of foundational tasks (adjust based on your project):
- [ ] T004 Setup database schema and migrations framework
- [ ] T005 [P] Implement authentication/authorization framework
- [ ] T006 [P] Setup API routing and middleware structure
- [ ] T007 Create base models/entities that all stories depend on
- [ ] T008 Configure error handling and logging infrastructure
- [ ] T009 Setup environment configuration management
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
---
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 1
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T016 [US1] Add validation and error handling
- [ ] T017 [US1] Add logging for user story 1 operations
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
---
## Phase 4: User Story 2 - [Title] (Priority: P2)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 2
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
---
## Phase 5: User Story 3 - [Title] (Priority: P3)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 3
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
**Checkpoint**: All user stories should now be independently functional
---
[Add more user story phases as needed, following the same pattern]
---
## Phase N: Polish & Cross-Cutting Concerns
**Purpose**: Improvements that affect multiple user stories
- [ ] TXXX [P] Documentation updates in docs/
- [ ] TXXX Code cleanup and refactoring
- [ ] TXXX Performance optimization across all stories
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
- [ ] TXXX Security hardening
- [ ] TXXX Run quickstart.md validation
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
- User stories can then proceed in parallel (if staffed)
- Or sequentially in priority order (P1 → P2 → P3)
- **Polish (Final Phase)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
### Within Each User Story
- Tests (if included) MUST be written and FAIL before implementation
- Models before services
- Services before endpoints
- Core implementation before integration
- Story complete before moving to next priority
### Parallel Opportunities
- All Setup tasks marked [P] can run in parallel
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
- All tests for a user story marked [P] can run in parallel
- Models within a story marked [P] can run in parallel
- Different user stories can be worked on in parallel by different team members
---
## Parallel Example: User Story 1
```bash
# Launch all tests for User Story 1 together (if tests requested):
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
# Launch all models for User Story 1 together:
Task: "Create [Entity1] model in src/models/[entity1].py"
Task: "Create [Entity2] model in src/models/[entity2].py"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
3. Complete Phase 3: User Story 1
4. **STOP and VALIDATE**: Test User Story 1 independently
5. Deploy/demo if ready
### Incremental Delivery
1. Complete Setup + Foundational → Foundation ready
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
3. Add User Story 2 → Test independently → Deploy/Demo
4. Add User Story 3 → Test independently → Deploy/Demo
5. Each story adds value without breaking previous stories
### Parallel Team Strategy
With multiple developers:
1. Team completes Setup + Foundational together
2. Once Foundational is done:
- Developer A: User Story 1
- Developer B: User Story 2
- Developer C: User Story 3
3. Stories complete and integrate independently
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story should be independently completable and testable
- Verify tests fail before implementing
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Script to delete tasks with RUNNING status from the database."""
from sqlalchemy.orm import Session
from src.core.database import TasksSessionLocal
from src.models.task import TaskRecord
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()
if __name__ == "__main__":
delete_running_tasks()

Binary file not shown.

View File

@@ -1,14 +1,46 @@
fastapi annotated-doc==0.0.4
uvicorn annotated-types==0.7.0
pydantic anyio==4.12.0
authlib APScheduler==3.11.2
python-multipart attrs==25.4.0
starlette Authlib==1.6.6
jsonschema certifi==2025.11.12
requests cffi==2.0.0
keyring charset-normalizer==3.4.4
httpx click==8.3.1
PyYAML cryptography==46.0.3
websockets fastapi==0.126.0
rapidfuzz greenlet==3.3.0
sqlalchemy h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.11
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.3.0
jeepney==0.9.0
jsonschema==4.25.1
jsonschema-specifications==2025.9.1
keyring==25.7.0
more-itertools==10.8.0
pycparser==2.23
pydantic==2.12.5
pydantic_core==2.41.5
python-multipart==0.0.21
PyYAML==6.0.3
RapidFuzz==3.14.3
referencing==0.37.0
requests==2.32.5
rpds-py==0.30.0
SecretStorage==3.5.0
SQLAlchemy==2.0.45
starlette==0.50.0
typing-inspection==0.4.2
typing_extensions==4.15.0
tzlocal==5.3.1
urllib3==2.6.2
uvicorn==0.38.0
websockets==15.0.1
pandas
psycopg2-binary
openpyxl

View File

@@ -31,6 +31,12 @@ oauth2_scheme = OAuth2AuthorizationCodeBearer(
tokenUrl="https://your-adfs-server/adfs/oauth2/token", tokenUrl="https://your-adfs-server/adfs/oauth2/token",
) )
# [DEF:get_current_user:Function]
# @PURPOSE: Dependency to get the current user from the ADFS token.
# @PARAM: token (str) - The OAuth2 bearer token.
# @PRE: token should be provided via Authorization header.
# @POST: Returns user details if authenticated, else raises 401.
# @RETURN: Dict[str, str] - User information.
async def get_current_user(token: str = Depends(oauth2_scheme)): async def get_current_user(token: str = Depends(oauth2_scheme)):
""" """
Dependency to get the current user from the ADFS token. Dependency to get the current user from the ADFS token.
@@ -49,4 +55,5 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
) )
# A real implementation would return a user object. # A real implementation would return a user object.
return {"placeholder_user": "user@example.com"} return {"placeholder_user": "user@example.com"}
# [/DEF] # [/DEF:get_current_user:Function]
# [/DEF:AuthModule:Module]

View File

@@ -1 +1 @@
from . import plugins, tasks, settings from . import plugins, tasks, settings, connections

View File

@@ -0,0 +1,100 @@
# [DEF:ConnectionsRouter:Module]
# @SEMANTICS: api, router, connections, database
# @PURPOSE: Defines the FastAPI router for managing external database connections.
# @LAYER: UI (API)
# @RELATION: Depends on SQLAlchemy session.
# @CONSTRAINT: Must use belief_scope for logging.
# [SECTION: IMPORTS]
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...models.connection import ConnectionConfig
from pydantic import BaseModel, Field
from datetime import datetime
from ...core.logger import logger, belief_scope
# [/SECTION]
router = APIRouter()
# [DEF:ConnectionSchema:Class]
# @PURPOSE: Pydantic model for connection response.
class ConnectionSchema(BaseModel):
id: str
name: str
type: str
host: Optional[str] = None
port: Optional[int] = None
database: Optional[str] = None
username: Optional[str] = None
created_at: datetime
class Config:
orm_mode = True
# [/DEF:ConnectionSchema:Class]
# [DEF:ConnectionCreate:Class]
# @PURPOSE: Pydantic model for creating a connection.
class ConnectionCreate(BaseModel):
name: str
type: str
host: Optional[str] = None
port: Optional[int] = None
database: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
# [/DEF:ConnectionCreate:Class]
# [DEF:list_connections:Function]
# @PURPOSE: Lists all saved connections.
# @PRE: Database session is active.
# @POST: Returns list of connection configs.
# @PARAM: db (Session) - Database session.
# @RETURN: List[ConnectionSchema] - List of connections.
@router.get("", response_model=List[ConnectionSchema])
async def list_connections(db: Session = Depends(get_db)):
with belief_scope("ConnectionsRouter.list_connections"):
connections = db.query(ConnectionConfig).all()
return connections
# [/DEF:list_connections:Function]
# [DEF:create_connection:Function]
# @PURPOSE: Creates a new connection configuration.
# @PRE: Connection name is unique.
# @POST: Connection is saved to DB.
# @PARAM: connection (ConnectionCreate) - Config data.
# @PARAM: db (Session) - Database session.
# @RETURN: ConnectionSchema - Created connection.
@router.post("", response_model=ConnectionSchema, status_code=status.HTTP_201_CREATED)
async def create_connection(connection: ConnectionCreate, db: Session = Depends(get_db)):
with belief_scope("ConnectionsRouter.create_connection", f"name={connection.name}"):
db_connection = ConnectionConfig(**connection.dict())
db.add(db_connection)
db.commit()
db.refresh(db_connection)
logger.info(f"[ConnectionsRouter.create_connection][Success] Created connection {db_connection.id}")
return db_connection
# [/DEF:create_connection:Function]
# [DEF:delete_connection:Function]
# @PURPOSE: Deletes a connection configuration.
# @PRE: Connection ID exists.
# @POST: Connection is removed from DB.
# @PARAM: connection_id (str) - ID to delete.
# @PARAM: db (Session) - Database session.
# @RETURN: None.
@router.delete("/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_connection(connection_id: str, db: Session = Depends(get_db)):
with belief_scope("ConnectionsRouter.delete_connection", f"id={connection_id}"):
db_connection = db.query(ConnectionConfig).filter(ConnectionConfig.id == connection_id).first()
if not db_connection:
logger.error(f"[ConnectionsRouter.delete_connection][State] Connection {connection_id} not found")
raise HTTPException(status_code=404, detail="Connection not found")
db.delete(db_connection)
db.commit()
logger.info(f"[ConnectionsRouter.delete_connection][Success] Deleted connection {connection_id}")
return
# [/DEF:delete_connection:Function]
# [/DEF:ConnectionsRouter:Module]

View File

@@ -11,68 +11,112 @@
# [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 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 from ...core.config_models import Environment as EnvModel
from ...core.logger import belief_scope
# [/SECTION] # [/SECTION]
router = APIRouter(prefix="/api/environments", tags=["environments"]) router = APIRouter()
# [DEF:ScheduleSchema:DataClass]
class ScheduleSchema(BaseModel):
enabled: bool = False
cron_expression: str = Field(..., pattern=r'^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|((((\d+,)*\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})$')
# [/DEF:ScheduleSchema:DataClass]
# [DEF:EnvironmentResponse:DataClass] # [DEF:EnvironmentResponse:DataClass]
class EnvironmentResponse(BaseModel): class EnvironmentResponse(BaseModel):
id: str id: str
name: str name: str
url: str url: str
# [/DEF:EnvironmentResponse] backup_schedule: Optional[ScheduleSchema] = None
# [/DEF:EnvironmentResponse:DataClass]
# [DEF:DatabaseResponse:DataClass] # [DEF:DatabaseResponse:DataClass]
class DatabaseResponse(BaseModel): class DatabaseResponse(BaseModel):
uuid: str uuid: str
database_name: str database_name: str
engine: Optional[str] engine: Optional[str]
# [/DEF:DatabaseResponse] # [/DEF:DatabaseResponse:DataClass]
# [DEF:get_environments:Function] # [DEF:get_environments:Function]
# @PURPOSE: List all configured environments. # @PURPOSE: List all configured environments.
# @PRE: config_manager is injected via Depends.
# @POST: Returns a list of EnvironmentResponse objects.
# @RETURN: List[EnvironmentResponse] # @RETURN: List[EnvironmentResponse]
@router.get("", response_model=List[EnvironmentResponse]) @router.get("", response_model=List[EnvironmentResponse])
async def get_environments(config_manager=Depends(get_config_manager)): async def get_environments(config_manager=Depends(get_config_manager)):
envs = config_manager.get_environments() with belief_scope("get_environments"):
envs = config_manager.get_environments()
# Ensure envs is a list # Ensure envs is a list
if not isinstance(envs, list): if not isinstance(envs, list):
envs = [] envs = []
return [EnvironmentResponse(id=e.id, name=e.name, url=e.url) for e in envs] return [
# [/DEF:get_environments] EnvironmentResponse(
id=e.id,
name=e.name,
url=e.url,
backup_schedule=ScheduleSchema(
enabled=e.backup_schedule.enabled,
cron_expression=e.backup_schedule.cron_expression
) if e.backup_schedule else None
) for e in envs
]
# [/DEF:get_environments:Function]
# [DEF:update_environment_schedule:Function]
# @PURPOSE: Update backup schedule for an environment.
# @PRE: Environment id exists, schedule is valid ScheduleSchema.
# @POST: Backup schedule updated and scheduler reloaded.
# @PARAM: id (str) - The environment ID.
# @PARAM: schedule (ScheduleSchema) - The new schedule.
@router.put("/{id}/schedule")
async def update_environment_schedule(
id: str,
schedule: ScheduleSchema,
config_manager=Depends(get_config_manager),
scheduler_service=Depends(get_scheduler_service)
):
with belief_scope("update_environment_schedule", f"id={id}"):
envs = config_manager.get_environments()
env = next((e for e in envs if e.id == id), None)
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
# Update environment config
env.backup_schedule.enabled = schedule.enabled
env.backup_schedule.cron_expression = schedule.cron_expression
config_manager.update_environment(id, env)
# Refresh scheduler
scheduler_service.load_schedules()
return {"message": "Schedule updated successfully"}
# [/DEF:update_environment_schedule:Function]
# [DEF:get_environment_databases:Function] # [DEF:get_environment_databases:Function]
# @PURPOSE: Fetch the list of databases from a specific environment. # @PURPOSE: Fetch the list of databases from a specific environment.
# @PRE: Environment id exists.
# @POST: Returns a list of database summaries from the environment.
# @PARAM: id (str) - The environment ID. # @PARAM: id (str) - The environment ID.
# @RETURN: List[Dict] - List of databases. # @RETURN: List[Dict] - List of databases.
@router.get("/{id}/databases") @router.get("/{id}/databases")
async def get_environment_databases(id: str, config_manager=Depends(get_config_manager)): async def get_environment_databases(id: str, config_manager=Depends(get_config_manager)):
envs = config_manager.get_environments() with belief_scope("get_environment_databases", f"id={id}"):
envs = config_manager.get_environments()
env = next((e for e in envs if e.id == id), None) env = next((e for e in envs if e.id == 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")
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)}")
# [/DEF:get_environment_databases] # [/DEF:get_environment_databases:Function]
# [/DEF:backend.src.api.routes.environments] # [/DEF:backend.src.api.routes.environments:Module]

View File

@@ -13,9 +13,10 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
from backend.src.dependencies import get_config_manager from ...core.logger import belief_scope
from backend.src.core.database import get_db from ...dependencies import get_config_manager
from backend.src.models.mapping import DatabaseMapping from ...core.database import get_db
from ...models.mapping import DatabaseMapping
from pydantic import BaseModel from pydantic import BaseModel
# [/SECTION] # [/SECTION]
@@ -29,7 +30,7 @@ class MappingCreate(BaseModel):
target_db_uuid: str target_db_uuid: str
source_db_name: str source_db_name: str
target_db_name: str target_db_name: str
# [/DEF:MappingCreate] # [/DEF:MappingCreate:DataClass]
# [DEF:MappingResponse:DataClass] # [DEF:MappingResponse:DataClass]
class MappingResponse(BaseModel): class MappingResponse(BaseModel):
@@ -43,68 +44,77 @@ class MappingResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
# [/DEF:MappingResponse] # [/DEF:MappingResponse:DataClass]
# [DEF:SuggestRequest:DataClass] # [DEF:SuggestRequest:DataClass]
class SuggestRequest(BaseModel): class SuggestRequest(BaseModel):
source_env_id: str source_env_id: str
target_env_id: str target_env_id: str
# [/DEF:SuggestRequest] # [/DEF:SuggestRequest:DataClass]
# [DEF:get_mappings:Function] # [DEF:get_mappings:Function]
# @PURPOSE: List all saved database mappings. # @PURPOSE: List all saved database mappings.
# @PRE: db session is injected.
# @POST: Returns filtered list of DatabaseMapping records.
@router.get("", response_model=List[MappingResponse]) @router.get("", response_model=List[MappingResponse])
async def get_mappings( async def get_mappings(
source_env_id: Optional[str] = None, source_env_id: Optional[str] = None,
target_env_id: Optional[str] = None, target_env_id: Optional[str] = None,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
query = db.query(DatabaseMapping) with belief_scope("get_mappings"):
query = db.query(DatabaseMapping)
if source_env_id: if source_env_id:
query = query.filter(DatabaseMapping.source_env_id == source_env_id) query = query.filter(DatabaseMapping.source_env_id == source_env_id)
if target_env_id: if target_env_id:
query = query.filter(DatabaseMapping.target_env_id == target_env_id) query = query.filter(DatabaseMapping.target_env_id == target_env_id)
return query.all() return query.all()
# [/DEF:get_mappings] # [/DEF:get_mappings:Function]
# [DEF:create_mapping:Function] # [DEF:create_mapping:Function]
# @PURPOSE: Create or update a database mapping. # @PURPOSE: Create or update a database mapping.
# @PRE: mapping is valid MappingCreate, db session is injected.
# @POST: DatabaseMapping created or updated in database.
@router.post("", response_model=MappingResponse) @router.post("", response_model=MappingResponse)
async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)): async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
# Check if mapping already exists with belief_scope("create_mapping"):
existing = db.query(DatabaseMapping).filter( # Check if mapping already exists
DatabaseMapping.source_env_id == mapping.source_env_id, existing = db.query(DatabaseMapping).filter(
DatabaseMapping.target_env_id == mapping.target_env_id, DatabaseMapping.source_env_id == mapping.source_env_id,
DatabaseMapping.source_db_uuid == mapping.source_db_uuid DatabaseMapping.target_env_id == mapping.target_env_id,
).first() DatabaseMapping.source_db_uuid == mapping.source_db_uuid
).first()
if existing:
existing.target_db_uuid = mapping.target_db_uuid if existing:
existing.target_db_name = mapping.target_db_name existing.target_db_uuid = mapping.target_db_uuid
existing.target_db_name = mapping.target_db_name
db.commit()
db.refresh(existing)
return existing
new_mapping = DatabaseMapping(**mapping.dict())
db.add(new_mapping)
db.commit() db.commit()
db.refresh(existing) db.refresh(new_mapping)
return existing return new_mapping
# [/DEF:create_mapping:Function]
new_mapping = DatabaseMapping(**mapping.dict())
db.add(new_mapping)
db.commit()
db.refresh(new_mapping)
return new_mapping
# [/DEF:create_mapping]
# [DEF:suggest_mappings_api:Function] # [DEF:suggest_mappings_api:Function]
# @PURPOSE: Get suggested mappings based on fuzzy matching. # @PURPOSE: Get suggested mappings based on fuzzy matching.
# @PRE: request is valid SuggestRequest, config_manager is injected.
# @POST: Returns mapping suggestions.
@router.post("/suggest") @router.post("/suggest")
async def suggest_mappings_api( async def suggest_mappings_api(
request: SuggestRequest, request: SuggestRequest,
config_manager=Depends(get_config_manager) config_manager=Depends(get_config_manager)
): ):
from backend.src.services.mapping_service import MappingService with belief_scope("suggest_mappings_api"):
service = MappingService(config_manager) from backend.src.services.mapping_service import MappingService
try: service = MappingService(config_manager)
return await service.get_suggestions(request.source_env_id, request.target_env_id) try:
except Exception as e: return await service.get_suggestions(request.source_env_id, request.target_env_id)
raise HTTPException(status_code=500, detail=str(e)) except Exception as e:
# [/DEF:suggest_mappings_api] raise HTTPException(status_code=500, detail=str(e))
# [/DEF:suggest_mappings_api:Function]
# [/DEF:backend.src.api.routes.mappings] # [/DEF:backend.src.api.routes.mappings:Module]

View File

@@ -7,10 +7,10 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict from typing import List, Dict
from backend.src.dependencies import get_config_manager, get_task_manager from ...dependencies import get_config_manager, get_task_manager
from backend.src.models.dashboard import DashboardMetadata, DashboardSelection from ...models.dashboard import DashboardMetadata, DashboardSelection
from backend.src.core.superset_client import SupersetClient from ...core.superset_client import SupersetClient
from superset_tool.models import SupersetConfig from ...core.logger import belief_scope
router = APIRouter(prefix="/api", tags=["migration"]) router = APIRouter(prefix="/api", tags=["migration"])
@@ -22,22 +22,16 @@ 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] # [/DEF:get_dashboards:Function]
# [DEF:execute_migration:Function] # [DEF:execute_migration:Function]
# @PURPOSE: Execute the migration of selected dashboards. # @PURPOSE: Execute the migration of selected dashboards.
@@ -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")
@@ -71,6 +66,6 @@ async def execute_migration(selection: DashboardSelection, config_manager=Depend
except Exception as e: except Exception as e:
logger.error(f"Task creation failed: {e}") logger.error(f"Task creation failed: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
# [/DEF:execute_migration] # [/DEF:execute_migration:Function]
# [/DEF:backend.src.api.routes.migration] # [/DEF:backend.src.api.routes.migration:Module]

View File

@@ -8,15 +8,23 @@ from fastapi import APIRouter, Depends
from ...core.plugin_base import PluginConfig from ...core.plugin_base import PluginConfig
from ...dependencies import get_plugin_loader from ...dependencies import get_plugin_loader
from ...core.logger import belief_scope
router = APIRouter() router = APIRouter()
@router.get("/", response_model=List[PluginConfig]) # [DEF:list_plugins:Function]
# @PURPOSE: Retrieve a list of all available plugins.
# @PRE: plugin_loader is injected via Depends.
# @POST: Returns a list of PluginConfig objects.
# @RETURN: List[PluginConfig] - List of registered plugins.
@router.get("", response_model=List[PluginConfig])
async def list_plugins( async def list_plugins(
plugin_loader = Depends(get_plugin_loader) plugin_loader = Depends(get_plugin_loader)
): ):
""" with belief_scope("list_plugins"):
Retrieve a list of all available plugins. """
""" Retrieve a list of all available plugins.
return plugin_loader.get_all_plugin_configs() """
# [/DEF] return plugin_loader.get_all_plugin_configs()
# [/DEF:list_plugins:Function]
# [/DEF:PluginsRouter:Module]

View File

@@ -15,9 +15,8 @@ from typing import List
from ...core.config_models import AppConfig, Environment, GlobalSettings from ...core.config_models import AppConfig, Environment, GlobalSettings
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 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]
@@ -25,43 +24,54 @@ router = APIRouter()
# [DEF:get_settings:Function] # [DEF:get_settings:Function]
# @PURPOSE: Retrieves all application settings. # @PURPOSE: Retrieves all application settings.
# @PRE: Config manager is available.
# @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)):
logger.info("[get_settings][Entry] Fetching all settings") with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings")
config = config_manager.get_config().copy(deep=True) config = config_manager.get_config().copy(deep=True)
# Mask passwords # Mask passwords
for env in config.environments: for env in config.environments:
if env.password: if env.password:
env.password = "********" env.password = "********"
return config return config
# [/DEF:get_settings] # [/DEF:get_settings:Function]
# [DEF:update_global_settings:Function] # [DEF:update_global_settings:Function]
# @PURPOSE: Updates global application settings. # @PURPOSE: Updates global application settings.
# @PRE: New settings are provided.
# @POST: Global settings are updated.
# @PARAM: settings (GlobalSettings) - The new global settings. # @PARAM: settings (GlobalSettings) - The new global settings.
# @RETURN: GlobalSettings - The updated settings. # @RETURN: GlobalSettings - The updated settings.
@router.patch("/global", response_model=GlobalSettings) @router.patch("/global", response_model=GlobalSettings)
async def update_global_settings( async def update_global_settings(
settings: GlobalSettings, settings: GlobalSettings,
config_manager: ConfigManager = Depends(get_config_manager) config_manager: ConfigManager = Depends(get_config_manager)
): ):
logger.info("[update_global_settings][Entry] Updating global settings") with belief_scope("update_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] # [/DEF:update_global_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.
# @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments. # @RETURN: List[Environment] - List of environments.
@router.get("/environments", response_model=List[Environment]) @router.get("/environments", response_model=List[Environment])
async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)): async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)):
logger.info("[get_environments][Entry] Fetching environments") with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
return config_manager.get_environments() return config_manager.get_environments()
# [/DEF:get_environments] # [/DEF:get_environments:Function]
# [DEF:add_environment:Function] # [DEF:add_environment:Function]
# @PURPOSE: Adds a new Superset environment. # @PURPOSE: Adds a new Superset environment.
# @PRE: Environment data is valid and reachable.
# @POST: Environment is added to config.
# @PARAM: env (Environment) - The environment to add. # @PARAM: env (Environment) - The environment to add.
# @RETURN: Environment - The added environment. # @RETURN: Environment - The added environment.
@router.post("/environments", response_model=Environment) @router.post("/environments", response_model=Environment)
@@ -69,21 +79,12 @@ async def add_environment(
env: Environment, env: Environment,
config_manager: ConfigManager = Depends(get_config_manager) config_manager: ConfigManager = Depends(get_config_manager)
): ):
logger.info(f"[add_environment][Entry] Adding environment {env.id}") with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
# 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}")
@@ -91,20 +92,23 @@ async def add_environment(
config_manager.add_environment(env) config_manager.add_environment(env)
return env return env
# [/DEF:add_environment] # [/DEF:add_environment:Function]
# [DEF:update_environment:Function] # [DEF:update_environment:Function]
# @PURPOSE: Updates an existing Superset environment. # @PURPOSE: Updates an existing Superset environment.
# @PRE: ID and valid environment data are provided.
# @POST: Environment is updated in config.
# @PARAM: id (str) - The ID of the environment to update. # @PARAM: id (str) - The ID of the environment to update.
# @PARAM: env (Environment) - The updated environment data. # @PARAM: env (Environment) - The updated environment data.
# @RETURN: Environment - The updated environment. # @RETURN: Environment - The updated environment.
@router.put("/environments/{id}", response_model=Environment) @router.put("/environments/{id}", response_model=Environment)
async def update_environment( async def update_environment(
id: str, id: str,
env: Environment, env: Environment,
config_manager: ConfigManager = Depends(get_config_manager) config_manager: ConfigManager = Depends(get_config_manager)
): ):
logger.info(f"[update_environment][Entry] Updating environment {id}") with belief_scope("update_environment"):
logger.info(f"[update_environment][Entry] Updating environment {id}")
# If password is masked, we need the real one for validation # If password is masked, we need the real one for validation
env_to_validate = env.copy(deep=True) env_to_validate = env.copy(deep=True)
@@ -115,17 +119,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}")
@@ -134,23 +128,28 @@ async def update_environment(
if config_manager.update_environment(id, env): if config_manager.update_environment(id, env):
return env return env
raise HTTPException(status_code=404, detail=f"Environment {id} not found") raise HTTPException(status_code=404, detail=f"Environment {id} not found")
# [/DEF:update_environment] # [/DEF:update_environment:Function]
# [DEF:delete_environment:Function] # [DEF:delete_environment:Function]
# @PURPOSE: Deletes a Superset environment. # @PURPOSE: Deletes a Superset environment.
# @PRE: ID is provided.
# @POST: Environment is removed from config.
# @PARAM: id (str) - The ID of the environment to delete. # @PARAM: id (str) - The ID of the environment to delete.
@router.delete("/environments/{id}") @router.delete("/environments/{id}")
async def delete_environment( async def delete_environment(
id: str, id: str,
config_manager: ConfigManager = Depends(get_config_manager) config_manager: ConfigManager = Depends(get_config_manager)
): ):
logger.info(f"[delete_environment][Entry] Deleting environment {id}") with belief_scope("delete_environment"):
logger.info(f"[delete_environment][Entry] Deleting environment {id}")
config_manager.delete_environment(id) config_manager.delete_environment(id)
return {"message": f"Environment {id} deleted"} return {"message": f"Environment {id} deleted"}
# [/DEF:delete_environment] # [/DEF:delete_environment:Function]
# [DEF:test_environment_connection:Function] # [DEF:test_environment_connection:Function]
# @PURPOSE: Tests the connection to a Superset environment. # @PURPOSE: Tests the connection to a Superset environment.
# @PRE: ID is provided.
# @POST: Returns success or error status.
# @PARAM: id (str) - The ID of the environment to test. # @PARAM: id (str) - The ID of the environment to test.
# @RETURN: dict - Success message or error. # @RETURN: dict - Success message or error.
@router.post("/environments/{id}/test") @router.post("/environments/{id}/test")
@@ -158,7 +157,8 @@ async def test_environment_connection(
id: str, id: str,
config_manager: ConfigManager = Depends(get_config_manager) config_manager: ConfigManager = Depends(get_config_manager)
): ):
logger.info(f"[test_environment_connection][Entry] Testing environment {id}") with belief_scope("test_environment_connection"):
logger.info(f"[test_environment_connection][Entry] Testing environment {id}")
# Find environment # Find environment
env = next((e for e in config_manager.get_environments() if e.id == id), None) env = next((e for e in config_manager.get_environments() if e.id == id), None)
@@ -166,21 +166,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})
@@ -190,10 +177,12 @@ async def test_environment_connection(
except Exception as e: except Exception as e:
logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}") logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}")
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
# [/DEF:test_environment_connection] # [/DEF:test_environment_connection:Function]
# [DEF:validate_backup_path:Function] # [DEF:validate_backup_path:Function]
# @PURPOSE: Validates if a backup path exists and is writable. # @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. # @PARAM: path (str) - The path to validate.
# @RETURN: dict - Validation result. # @RETURN: dict - Validation result.
@router.post("/validate-path") @router.post("/validate-path")
@@ -201,11 +190,12 @@ async def validate_backup_path(
path_data: dict, path_data: dict,
config_manager: ConfigManager = Depends(get_config_manager) config_manager: ConfigManager = Depends(get_config_manager)
): ):
path = path_data.get("path") with belief_scope("validate_backup_path"):
if not path: path = path_data.get("path")
raise HTTPException(status_code=400, detail="Path is required") if not path:
raise HTTPException(status_code=400, detail="Path is required")
logger.info(f"[validate_backup_path][Entry] Validating path: {path}")
logger.info(f"[validate_backup_path][Entry] Validating path: {path}")
valid, message = config_manager.validate_path(path) valid, message = config_manager.validate_path(path)
@@ -213,6 +203,6 @@ async def validate_backup_path(
return {"status": "error", "message": message} return {"status": "error", "message": message}
return {"status": "success", "message": message} return {"status": "success", "message": message}
# [/DEF:validate_backup_path] # [/DEF:validate_backup_path:Function]
# [/DEF:SettingsRouter] # [/DEF:SettingsRouter:Module]

View File

@@ -6,6 +6,7 @@
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...dependencies import get_task_manager from ...dependencies import get_task_manager
@@ -23,6 +24,13 @@ class ResumeTaskRequest(BaseModel):
passwords: Dict[str, str] passwords: Dict[str, str]
@router.post("", response_model=Task, status_code=status.HTTP_201_CREATED) @router.post("", response_model=Task, status_code=status.HTTP_201_CREATED)
# [DEF:create_task:Function]
# @PURPOSE: Create and start a new task for a given plugin.
# @PARAM: request (CreateTaskRequest) - The request body containing plugin_id and params.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: plugin_id must exist and params must be valid for that plugin.
# @POST: A new task is created and started.
# @RETURN: Task - The created task instance.
async def create_task( async def create_task(
request: CreateTaskRequest, request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager) task_manager: TaskManager = Depends(get_task_manager)
@@ -30,16 +38,27 @@ async def create_task(
""" """
Create and start a new task for a given plugin. Create and start a new task for a given plugin.
""" """
try: with belief_scope("create_task"):
task = await task_manager.create_task( try:
plugin_id=request.plugin_id, task = await task_manager.create_task(
params=request.params plugin_id=request.plugin_id,
) params=request.params
return task )
except ValueError as e: return task
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:create_task:Function]
@router.get("", response_model=List[Task]) @router.get("", response_model=List[Task])
# [DEF:list_tasks:Function]
# @PURPOSE: Retrieve a list of tasks with pagination and optional status filter.
# @PARAM: limit (int) - Maximum number of tasks to return.
# @PARAM: offset (int) - Number of tasks to skip.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_manager must be available.
# @POST: Returns a list of tasks.
# @RETURN: List[Task] - List of tasks.
async def list_tasks( async def list_tasks(
limit: int = 10, limit: int = 10,
offset: int = 0, offset: int = 0,
@@ -49,9 +68,18 @@ async def list_tasks(
""" """
Retrieve a list of tasks with pagination and optional status filter. Retrieve a list of tasks with pagination and optional status filter.
""" """
return task_manager.get_tasks(limit=limit, offset=offset, status=status) with belief_scope("list_tasks"):
return task_manager.get_tasks(limit=limit, offset=offset, status=status)
# [/DEF:list_tasks:Function]
@router.get("/{task_id}", response_model=Task) @router.get("/{task_id}", response_model=Task)
# [DEF:get_task:Function]
# @PURPOSE: Retrieve the details of a specific task.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns task details or raises 404.
# @RETURN: Task - The task details.
async def get_task( async def get_task(
task_id: str, task_id: str,
task_manager: TaskManager = Depends(get_task_manager) task_manager: TaskManager = Depends(get_task_manager)
@@ -59,12 +87,21 @@ async def get_task(
""" """
Retrieve the details of a specific task. Retrieve the details of a specific task.
""" """
task = task_manager.get_task(task_id) with belief_scope("get_task"):
if not task: task = task_manager.get_task(task_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") if not task:
return task raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return task
# [/DEF:get_task:Function]
@router.get("/{task_id}/logs", response_model=List[LogEntry]) @router.get("/{task_id}/logs", response_model=List[LogEntry])
# [DEF:get_task_logs:Function]
# @PURPOSE: Retrieve logs for a specific task.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns a list of log entries or raises 404.
# @RETURN: List[LogEntry] - List of log entries.
async def get_task_logs( async def get_task_logs(
task_id: str, task_id: str,
task_manager: TaskManager = Depends(get_task_manager) task_manager: TaskManager = Depends(get_task_manager)
@@ -72,12 +109,22 @@ async def get_task_logs(
""" """
Retrieve logs for a specific task. Retrieve logs for a specific task.
""" """
task = task_manager.get_task(task_id) with belief_scope("get_task_logs"):
if not task: task = task_manager.get_task(task_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") if not task:
return task_manager.get_task_logs(task_id) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return task_manager.get_task_logs(task_id)
# [/DEF:get_task_logs:Function]
@router.post("/{task_id}/resolve", response_model=Task) @router.post("/{task_id}/resolve", response_model=Task)
# [DEF:resolve_task:Function]
# @PURPOSE: Resolve a task that is awaiting mapping.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: request (ResolveTaskRequest) - The resolution parameters.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task must be in AWAITING_MAPPING status.
# @POST: Task is resolved and resumes execution.
# @RETURN: Task - The updated task object.
async def resolve_task( async def resolve_task(
task_id: str, task_id: str,
request: ResolveTaskRequest, request: ResolveTaskRequest,
@@ -86,13 +133,23 @@ async def resolve_task(
""" """
Resolve a task that is awaiting mapping. Resolve a task that is awaiting mapping.
""" """
try: with belief_scope("resolve_task"):
await task_manager.resolve_task(task_id, request.resolution_params) try:
return task_manager.get_task(task_id) await task_manager.resolve_task(task_id, request.resolution_params)
except ValueError as e: return task_manager.get_task(task_id)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:resolve_task:Function]
@router.post("/{task_id}/resume", response_model=Task) @router.post("/{task_id}/resume", response_model=Task)
# [DEF:resume_task:Function]
# @PURPOSE: Resume a task that is awaiting input (e.g., passwords).
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: request (ResumeTaskRequest) - The input (passwords).
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task must be in AWAITING_INPUT status.
# @POST: Task resumes execution with provided input.
# @RETURN: Task - The updated task object.
async def resume_task( async def resume_task(
task_id: str, task_id: str,
request: ResumeTaskRequest, request: ResumeTaskRequest,
@@ -101,13 +158,21 @@ async def resume_task(
""" """
Resume a task that is awaiting input (e.g., passwords). Resume a task that is awaiting input (e.g., passwords).
""" """
try: with belief_scope("resume_task"):
task_manager.resume_task_with_password(task_id, request.passwords) try:
return task_manager.get_task(task_id) task_manager.resume_task_with_password(task_id, request.passwords)
except ValueError as e: return task_manager.get_task(task_id)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:resume_task:Function]
@router.delete("", status_code=status.HTTP_204_NO_CONTENT) @router.delete("", status_code=status.HTTP_204_NO_CONTENT)
# [DEF:clear_tasks:Function]
# @PURPOSE: Clear tasks matching the status filter.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_manager is available.
# @POST: Tasks are removed from memory/persistence.
async def clear_tasks( async def clear_tasks(
status: Optional[TaskStatus] = None, status: Optional[TaskStatus] = None,
task_manager: TaskManager = Depends(get_task_manager) task_manager: TaskManager = Depends(get_task_manager)
@@ -115,6 +180,8 @@ async def clear_tasks(
""" """
Clear tasks matching the status filter. If no filter, clears all non-running tasks. Clear tasks matching the status filter. If no filter, clears all non-running tasks.
""" """
task_manager.clear_tasks(status) with belief_scope("clear_tasks", f"status={status}"):
return task_manager.clear_tasks(status)
# [/DEF] return
# [/DEF:clear_tasks:Function]
# [/DEF:TasksRouter:Module]

View File

@@ -6,26 +6,21 @@
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 from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import asyncio import asyncio
import os import os
from .dependencies import get_task_manager from .dependencies import get_task_manager, get_scheduler_service
from .core.logger import logger from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections
from .core.database import init_db from .core.database import init_db
# Initialize database
init_db()
# [DEF:App:Global] # [DEF:App:Global]
# @SEMANTICS: app, fastapi, instance # @SEMANTICS: app, fastapi, instance
# @PURPOSE: The global FastAPI application instance. # @PURPOSE: The global FastAPI application instance.
@@ -34,6 +29,31 @@ app = FastAPI(
description="API for managing Superset automation tools and plugins.", description="API for managing Superset automation tools and plugins.",
version="1.0.0", version="1.0.0",
) )
# [/DEF:App:Global]
# [DEF:startup_event:Function]
# @PURPOSE: Handles application startup tasks, such as starting the scheduler.
# @PRE: None.
# @POST: Scheduler is started.
# Startup event
@app.on_event("startup")
async def startup_event():
with belief_scope("startup_event"):
scheduler = get_scheduler_service()
scheduler.start()
# [/DEF:startup_event:Function]
# [DEF:shutdown_event:Function]
# @PURPOSE: Handles application shutdown tasks, such as stopping the scheduler.
# @PRE: None.
# @POST: Scheduler is stopped.
# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
with belief_scope("shutdown_event"):
scheduler = get_scheduler_service()
scheduler.stop()
# [/DEF:shutdown_event:Function]
# Configure CORS # Configure CORS
app.add_middleware( app.add_middleware(
@@ -45,27 +65,38 @@ app.add_middleware(
) )
# [DEF:log_requests:Function]
# @PURPOSE: Middleware to log incoming HTTP requests and their response status.
# @PRE: request is a FastAPI Request object.
# @POST: Logs request and response details.
# @PARAM: request (Request) - The incoming request object.
# @PARAM: call_next (Callable) - The next middleware or route handler.
@app.middleware("http") @app.middleware("http")
async def log_requests(request: Request, call_next): async def log_requests(request: Request, call_next):
logger.info(f"[DEBUG] Incoming request: {request.method} {request.url.path}") with belief_scope("log_requests", f"{request.method} {request.url.path}"):
response = await call_next(request) logger.info(f"[DEBUG] Incoming request: {request.method} {request.url.path}")
logger.info(f"[DEBUG] Response status: {response.status_code} for {request.url.path}") response = await call_next(request)
return response logger.info(f"[DEBUG] Response status: {response.status_code} for {request.url.path}")
return response
# [/DEF:log_requests:Function]
# Include API routes # Include API routes
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"]) app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"]) app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"]) app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
app.include_router(environments.router) app.include_router(connections.router, prefix="/api/settings/connections", tags=["Connections"])
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)
# [DEF:WebSocketEndpoint:Endpoint] # [DEF:websocket_endpoint:Function]
# @SEMANTICS: websocket, logs, streaming, real-time # @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task.
# @PURPOSE: Provides a WebSocket endpoint for clients to connect to and receive real-time log entries for a specific task. # @PRE: task_id must be a valid task ID.
# @POST: WebSocket connection is managed and logs are streamed until disconnect.
@app.websocket("/ws/logs/{task_id}") @app.websocket("/ws/logs/{task_id}")
async def websocket_endpoint(websocket: WebSocket, task_id: str): async def websocket_endpoint(websocket: WebSocket, task_id: str):
await websocket.accept() with belief_scope("websocket_endpoint", f"task_id={task_id}"):
await websocket.accept()
logger.info(f"WebSocket connection accepted for task {task_id}") logger.info(f"WebSocket connection accepted for task {task_id}")
task_manager = get_task_manager() task_manager = get_task_manager()
queue = await task_manager.subscribe_logs(task_id) queue = await task_manager.subscribe_logs(task_id)
@@ -115,8 +146,7 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str):
logger.error(f"WebSocket error for task {task_id}: {e}") logger.error(f"WebSocket error for task {task_id}: {e}")
finally: finally:
task_manager.unsubscribe_logs(task_id, queue) task_manager.unsubscribe_logs(task_id, queue)
# [/DEF:websocket_endpoint:Function]
# [/DEF]
# [DEF:StaticFiles:Mount] # [DEF:StaticFiles:Mount]
# @SEMANTICS: static, frontend, spa # @SEMANTICS: static, frontend, spa
@@ -126,18 +156,33 @@ if frontend_path.exists():
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static") app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
# Serve other static files from the root of build directory # Serve other static files from the root of build directory
# [DEF:serve_spa:Function]
# @PURPOSE: Serves frontend static files or index.html for SPA routing.
# @PRE: file_path is requested by the client.
# @POST: Returns the requested file or index.html as a fallback.
@app.get("/{file_path:path}") @app.get("/{file_path:path}")
async def serve_spa(file_path: str): async def serve_spa(file_path: str):
full_path = frontend_path / file_path with belief_scope("serve_spa", f"path={file_path}"):
if full_path.is_file(): # Don't serve SPA for API routes that fell through
return FileResponse(str(full_path)) if file_path.startswith("api/"):
# Fallback to index.html for SPA routing logger.info(f"[DEBUG] API route fell through to serve_spa: {file_path}")
return FileResponse(str(frontend_path / "index.html")) raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
full_path = frontend_path / file_path
if full_path.is_file():
return FileResponse(str(full_path))
# Fallback to index.html for SPA routing
return FileResponse(str(frontend_path / "index.html"))
# [/DEF:serve_spa:Function]
else: else:
# [DEF:RootEndpoint:Endpoint] # [DEF:read_root:Function]
# @SEMANTICS: root, healthcheck # @PURPOSE: A simple root endpoint to confirm that the API is running when frontend is missing.
# @PURPOSE: A simple root endpoint to confirm that the API is running. # @PRE: None.
# @POST: Returns a JSON message indicating API status.
@app.get("/") @app.get("/")
async def read_root(): async def read_root():
return {"message": "Superset Tools API is running (Frontend build not found)"} with belief_scope("read_root"):
# [/DEF] return {"message": "Superset Tools API is running (Frontend build not found)"}
# [/DEF:read_root:Function]
# [/DEF:StaticFiles:Mount]
# [/DEF:AppModule:Module]

View File

@@ -16,7 +16,7 @@ import os
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Optional, List
from .config_models import AppConfig, Environment, GlobalSettings from .config_models import AppConfig, Environment, GlobalSettings
from .logger import logger, configure_logger from .logger import logger, configure_logger, belief_scope
# [/SECTION] # [/SECTION]
# [DEF:ConfigManager:Class] # [DEF:ConfigManager:Class]
@@ -30,30 +30,33 @@ class ConfigManager:
# @POST: self.config is an instance of AppConfig # @POST: self.config is an instance of AppConfig
# @PARAM: config_path (str) - Path to the configuration file. # @PARAM: config_path (str) - Path to the configuration file.
def __init__(self, config_path: str = "config.json"): def __init__(self, config_path: str = "config.json"):
# 1. Runtime check of @PRE with belief_scope("__init__"):
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string" # 1. Runtime check of @PRE
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string"
logger.info(f"[ConfigManager][Entry] Initializing with {config_path}")
logger.info(f"[ConfigManager][Entry] Initializing with {config_path}")
# 2. Logic implementation
self.config_path = Path(config_path) # 2. Logic implementation
self.config: AppConfig = self._load_config() self.config_path = Path(config_path)
self.config: AppConfig = self._load_config()
# Configure logger with loaded settings # Configure logger with loaded settings
configure_logger(self.config.settings.logging) configure_logger(self.config.settings.logging)
# 3. Runtime check of @POST # 3. Runtime check of @POST
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig" assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
logger.info(f"[ConfigManager][Exit] Initialized") logger.info(f"[ConfigManager][Exit] Initialized")
# [/DEF:__init__] # [/DEF:__init__:Function]
# [DEF:_load_config:Function] # [DEF:_load_config:Function]
# @PURPOSE: Loads the configuration from disk or creates a default one. # @PURPOSE: Loads the configuration from disk or creates a default one.
# @PRE: self.config_path is set.
# @POST: isinstance(return, AppConfig) # @POST: isinstance(return, AppConfig)
# @RETURN: AppConfig - The loaded or default configuration. # @RETURN: AppConfig - The loaded or default configuration.
def _load_config(self) -> AppConfig: def _load_config(self) -> AppConfig:
logger.debug(f"[_load_config][Entry] Loading from {self.config_path}") with belief_scope("_load_config"):
logger.debug(f"[_load_config][Entry] Loading from {self.config_path}")
if not self.config_path.exists(): if not self.config_path.exists():
logger.info(f"[_load_config][Action] Config file not found. Creating default.") logger.info(f"[_load_config][Action] Config file not found. Creating default.")
@@ -78,14 +81,16 @@ class ConfigManager:
environments=[], environments=[],
settings=GlobalSettings(backup_path="backups") settings=GlobalSettings(backup_path="backups")
) )
# [/DEF:_load_config] # [/DEF:_load_config:Function]
# [DEF:_save_config_to_disk:Function] # [DEF:_save_config_to_disk:Function]
# @PURPOSE: Saves the provided configuration object to disk. # @PURPOSE: Saves the provided configuration object to disk.
# @PRE: isinstance(config, AppConfig) # @PRE: isinstance(config, AppConfig)
# @POST: Configuration saved to disk.
# @PARAM: config (AppConfig) - The configuration to save. # @PARAM: config (AppConfig) - The configuration to save.
def _save_config_to_disk(self, config: AppConfig): def _save_config_to_disk(self, config: AppConfig):
logger.debug(f"[_save_config_to_disk][Entry] Saving to {self.config_path}") with belief_scope("_save_config_to_disk"):
logger.debug(f"[_save_config_to_disk][Entry] Saving to {self.config_path}")
# 1. Runtime check of @PRE # 1. Runtime check of @PRE
assert isinstance(config, AppConfig), "config must be an instance of AppConfig" assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
@@ -97,27 +102,35 @@ class ConfigManager:
logger.info(f"[_save_config_to_disk][Action] Configuration saved") logger.info(f"[_save_config_to_disk][Action] Configuration saved")
except Exception as e: except Exception as e:
logger.error(f"[_save_config_to_disk][Coherence:Failed] Failed to save: {e}") logger.error(f"[_save_config_to_disk][Coherence:Failed] Failed to save: {e}")
# [/DEF:_save_config_to_disk] # [/DEF:_save_config_to_disk:Function]
# [DEF:save:Function] # [DEF:save:Function]
# @PURPOSE: Saves the current configuration state to disk. # @PURPOSE: Saves the current configuration state to disk.
# @PRE: self.config is set.
# @POST: self._save_config_to_disk called.
def save(self): def save(self):
self._save_config_to_disk(self.config) with belief_scope("save"):
# [/DEF:save] self._save_config_to_disk(self.config)
# [/DEF:save:Function]
# [DEF:get_config:Function] # [DEF:get_config:Function]
# @PURPOSE: Returns the current configuration. # @PURPOSE: Returns the current configuration.
# @PRE: self.config is set.
# @POST: Returns self.config.
# @RETURN: AppConfig - The current configuration. # @RETURN: AppConfig - The current configuration.
def get_config(self) -> AppConfig: def get_config(self) -> AppConfig:
return self.config with belief_scope("get_config"):
# [/DEF:get_config] return self.config
# [/DEF:get_config:Function]
# [DEF:update_global_settings:Function] # [DEF:update_global_settings:Function]
# @PURPOSE: Updates the global settings and persists the change. # @PURPOSE: Updates the global settings and persists the change.
# @PRE: isinstance(settings, GlobalSettings) # @PRE: isinstance(settings, GlobalSettings)
# @POST: self.config.settings updated and saved.
# @PARAM: settings (GlobalSettings) - The new global settings. # @PARAM: settings (GlobalSettings) - The new global settings.
def update_global_settings(self, settings: GlobalSettings): def update_global_settings(self, settings: GlobalSettings):
logger.info(f"[update_global_settings][Entry] Updating settings") with belief_scope("update_global_settings"):
logger.info(f"[update_global_settings][Entry] Updating settings")
# 1. Runtime check of @PRE # 1. Runtime check of @PRE
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings" assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
@@ -130,14 +143,17 @@ class ConfigManager:
configure_logger(settings.logging) configure_logger(settings.logging)
logger.info(f"[update_global_settings][Exit] Settings updated") logger.info(f"[update_global_settings][Exit] Settings updated")
# [/DEF:update_global_settings] # [/DEF:update_global_settings:Function]
# [DEF:validate_path:Function] # [DEF:validate_path:Function]
# @PURPOSE: Validates if a path exists and is writable. # @PURPOSE: Validates if a path exists and is writable.
# @PRE: path is a string.
# @POST: Returns (bool, str) status.
# @PARAM: path (str) - The path to validate. # @PARAM: path (str) - The path to validate.
# @RETURN: tuple (bool, str) - (is_valid, message) # @RETURN: tuple (bool, str) - (is_valid, message)
def validate_path(self, path: str) -> tuple[bool, str]: def validate_path(self, path: str) -> tuple[bool, str]:
p = os.path.abspath(path) with belief_scope("validate_path"):
p = os.path.abspath(path)
if not os.path.exists(p): if not os.path.exists(p):
try: try:
os.makedirs(p, exist_ok=True) os.makedirs(p, exist_ok=True)
@@ -148,28 +164,50 @@ class ConfigManager:
return False, "Path is not writable" return False, "Path is not writable"
return True, "Path is valid and writable" return True, "Path is valid and writable"
# [/DEF:validate_path] # [/DEF:validate_path:Function]
# [DEF:get_environments:Function] # [DEF:get_environments:Function]
# @PURPOSE: Returns the list of configured environments. # @PURPOSE: Returns the list of configured environments.
# @PRE: self.config is set.
# @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments. # @RETURN: List[Environment] - List of environments.
def get_environments(self) -> List[Environment]: def get_environments(self) -> List[Environment]:
return self.config.environments with belief_scope("get_environments"):
# [/DEF:get_environments] return self.config.environments
# [/DEF:get_environments:Function]
# [DEF:has_environments:Function] # [DEF:has_environments:Function]
# @PURPOSE: Checks if at least one environment is configured. # @PURPOSE: Checks if at least one environment is configured.
# @PRE: self.config is set.
# @POST: Returns boolean indicating if environments exist.
# @RETURN: bool - True if at least one environment exists. # @RETURN: bool - True if at least one environment exists.
def has_environments(self) -> bool: def has_environments(self) -> bool:
return len(self.config.environments) > 0 with belief_scope("has_environments"):
# [/DEF:has_environments] return len(self.config.environments) > 0
# [/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)
# @POST: Environment added or updated in self.config.environments.
# @PARAM: env (Environment) - The environment to add. # @PARAM: env (Environment) - The environment to add.
def add_environment(self, env: Environment): def add_environment(self, env: Environment):
logger.info(f"[add_environment][Entry] Adding environment {env.id}") with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
# 1. Runtime check of @PRE # 1. Runtime check of @PRE
assert isinstance(env, Environment), "env must be an instance of Environment" assert isinstance(env, Environment), "env must be an instance of Environment"
@@ -181,16 +219,18 @@ class ConfigManager:
self.save() self.save()
logger.info(f"[add_environment][Exit] Environment added") logger.info(f"[add_environment][Exit] Environment added")
# [/DEF:add_environment] # [/DEF:add_environment:Function]
# [DEF:update_environment:Function] # [DEF:update_environment:Function]
# @PURPOSE: Updates an existing environment. # @PURPOSE: Updates an existing environment.
# @PRE: isinstance(env_id, str) and len(env_id) > 0 and isinstance(updated_env, Environment) # @PRE: isinstance(env_id, str) and len(env_id) > 0 and isinstance(updated_env, Environment)
# @POST: Returns True if environment was found and updated.
# @PARAM: env_id (str) - The ID of the environment to update. # @PARAM: env_id (str) - The ID of the environment to update.
# @PARAM: updated_env (Environment) - The updated environment data. # @PARAM: updated_env (Environment) - The updated environment data.
# @RETURN: bool - True if updated, False otherwise. # @RETURN: bool - True if updated, False otherwise.
def update_environment(self, env_id: str, updated_env: Environment) -> bool: def update_environment(self, env_id: str, updated_env: Environment) -> bool:
logger.info(f"[update_environment][Entry] Updating {env_id}") with belief_scope("update_environment"):
logger.info(f"[update_environment][Entry] Updating {env_id}")
# 1. Runtime check of @PRE # 1. Runtime check of @PRE
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string" assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
@@ -210,14 +250,16 @@ class ConfigManager:
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found") logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
return False return False
# [/DEF:update_environment] # [/DEF:update_environment:Function]
# [DEF:delete_environment:Function] # [DEF:delete_environment:Function]
# @PURPOSE: Deletes an environment by ID. # @PURPOSE: Deletes an environment by ID.
# @PRE: isinstance(env_id, str) and len(env_id) > 0 # @PRE: isinstance(env_id, str) and len(env_id) > 0
# @POST: Environment removed from self.config.environments if it existed.
# @PARAM: env_id (str) - The ID of the environment to delete. # @PARAM: env_id (str) - The ID of the environment to delete.
def delete_environment(self, env_id: str): def delete_environment(self, env_id: str):
logger.info(f"[delete_environment][Entry] Deleting {env_id}") with belief_scope("delete_environment"):
logger.info(f"[delete_environment][Entry] Deleting {env_id}")
# 1. Runtime check of @PRE # 1. Runtime check of @PRE
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string" assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
@@ -231,8 +273,8 @@ class ConfigManager:
logger.info(f"[delete_environment][Action] Deleted {env_id}") logger.info(f"[delete_environment][Action] Deleted {env_id}")
else: else:
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found") logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
# [/DEF:delete_environment] # [/DEF:delete_environment:Function]
# [/DEF:ConfigManager] # [/DEF:ConfigManager:Class]
# [/DEF:ConfigManagerModule] # [/DEF:ConfigManagerModule:Module]

View File

@@ -8,6 +8,13 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional from typing import List, Optional
# [DEF:Schedule:DataClass]
# @PURPOSE: Represents a backup schedule configuration.
class Schedule(BaseModel):
enabled: bool = False
cron_expression: str = "0 0 * * *" # Default: daily at midnight
# [/DEF:Schedule:DataClass]
# [DEF:Environment:DataClass] # [DEF:Environment:DataClass]
# @PURPOSE: Represents a Superset environment configuration. # @PURPOSE: Represents a Superset environment configuration.
class Environment(BaseModel): class Environment(BaseModel):
@@ -16,8 +23,11 @@ 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
# [/DEF:Environment] backup_schedule: Schedule = Field(default_factory=Schedule)
# [/DEF:Environment:DataClass]
# [DEF:LoggingConfig:DataClass] # [DEF:LoggingConfig:DataClass]
# @PURPOSE: Defines the configuration for the application's logging system. # @PURPOSE: Defines the configuration for the application's logging system.
@@ -27,7 +37,7 @@ class LoggingConfig(BaseModel):
max_bytes: int = 10 * 1024 * 1024 max_bytes: int = 10 * 1024 * 1024
backup_count: int = 5 backup_count: int = 5
enable_belief_state: bool = True enable_belief_state: bool = True
# [/DEF:LoggingConfig] # [/DEF:LoggingConfig:DataClass]
# [DEF:GlobalSettings:DataClass] # [DEF:GlobalSettings:DataClass]
# @PURPOSE: Represents global application settings. # @PURPOSE: Represents global application settings.
@@ -40,13 +50,13 @@ class GlobalSettings(BaseModel):
task_retention_days: int = 30 task_retention_days: int = 30
task_retention_limit: int = 100 task_retention_limit: int = 100
pagination_limit: int = 10 pagination_limit: int = 10
# [/DEF:GlobalSettings] # [/DEF:GlobalSettings:DataClass]
# [DEF:AppConfig:DataClass] # [DEF:AppConfig:DataClass]
# @PURPOSE: The root configuration model containing all application settings. # @PURPOSE: The root configuration model containing all application settings.
class AppConfig(BaseModel): class AppConfig(BaseModel):
environments: List[Environment] = [] environments: List[Environment] = []
settings: GlobalSettings settings: GlobalSettings
# [/DEF:AppConfig] # [/DEF:AppConfig:DataClass]
# [/DEF:ConfigModels] # [/DEF:ConfigModels:Module]

View File

@@ -11,38 +11,76 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker, Session
from backend.src.models.mapping import Base from ..models.mapping import Base
# Import models to ensure they're registered with Base
from ..models.task import TaskRecord
from ..models.connection import ConnectionConfig
from .logger import belief_scope
import os import os
# [/SECTION] # [/SECTION]
# [DEF:DATABASE_URL:Constant] # [DEF:DATABASE_URL:Constant]
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
# [/DEF:DATABASE_URL] # [/DEF:DATABASE_URL:Constant]
# [DEF:TASKS_DATABASE_URL:Constant]
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
# [/DEF:TASKS_DATABASE_URL:Constant]
# [DEF:engine:Variable] # [DEF:engine:Variable]
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:engine] # [/DEF:engine:Variable]
# [DEF:tasks_engine:Variable]
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:tasks_engine:Variable]
# [DEF:SessionLocal:Class] # [DEF:SessionLocal:Class]
# @PURPOSE: A session factory for the main mappings database.
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# [/DEF:SessionLocal] # [/DEF:SessionLocal:Class]
# [DEF:TasksSessionLocal:Class]
# @PURPOSE: A session factory for the tasks execution database.
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
# [/DEF:TasksSessionLocal:Class]
# [DEF:init_db:Function] # [DEF:init_db:Function]
# @PURPOSE: Initializes the database by creating all tables. # @PURPOSE: Initializes the database by creating all tables.
# @PRE: engine and tasks_engine are initialized.
# @POST: Database tables created.
def init_db(): def init_db():
Base.metadata.create_all(bind=engine) with belief_scope("init_db"):
# [/DEF:init_db] Base.metadata.create_all(bind=engine)
Base.metadata.create_all(bind=tasks_engine)
# [/DEF:init_db:Function]
# [DEF:get_db:Function] # [DEF:get_db:Function]
# @PURPOSE: Dependency for getting a database session. # @PURPOSE: Dependency for getting a database session.
# @PRE: SessionLocal is initialized.
# @POST: Session is closed after use. # @POST: Session is closed after use.
# @RETURN: Generator[Session, None, None] # @RETURN: Generator[Session, None, None]
def get_db(): def get_db():
db = SessionLocal() with belief_scope("get_db"):
try: db = SessionLocal()
yield db try:
finally: yield db
db.close() finally:
# [/DEF:get_db] db.close()
# [/DEF:get_db:Function]
# [/DEF:backend.src.core.database] # [DEF:get_tasks_db:Function]
# @PURPOSE: Dependency for getting a tasks database session.
# @PRE: TasksSessionLocal is initialized.
# @POST: Session is closed after use.
# @RETURN: Generator[Session, None, None]
def get_tasks_db():
with belief_scope("get_tasks_db"):
db = TasksSessionLocal()
try:
yield db
finally:
db.close()
# [/DEF:get_tasks_db:Function]
# [/DEF:backend.src.core.database:Module]

View File

@@ -22,13 +22,19 @@ _enable_belief_state = True
# [DEF:BeliefFormatter:Class] # [DEF:BeliefFormatter:Class]
# @PURPOSE: Custom logging formatter that adds belief state prefixes to log messages. # @PURPOSE: Custom logging formatter that adds belief state prefixes to log messages.
class BeliefFormatter(logging.Formatter): class BeliefFormatter(logging.Formatter):
# [DEF:format:Function]
# @PURPOSE: Formats the log record, adding belief state context if available.
# @PRE: record is a logging.LogRecord.
# @POST: Returns formatted string.
# @PARAM: record (logging.LogRecord) - The log record to format.
# @RETURN: str - The formatted log message.
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:BeliefFormatter] # [/DEF:format:Function]
# [/DEF:BeliefFormatter:Class]
# Re-using LogEntry from task_manager for consistency # Re-using LogEntry from task_manager for consistency
# [DEF:LogEntry:Class] # [DEF:LogEntry:Class]
@@ -40,10 +46,14 @@ class LogEntry(BaseModel):
message: str message: str
context: Optional[Dict[str, Any]] = None context: Optional[Dict[str, Any]] = None
# [/DEF] # [/DEF:LogEntry:Class]
# [DEF:BeliefScope:Function] # [DEF:belief_scope:Function]
# @PURPOSE: Context manager for structured Belief State logging. # @PURPOSE: Context manager for structured Belief State logging.
# @PARAM: anchor_id (str) - The identifier for the current semantic block.
# @PARAM: message (str) - Optional entry message.
# @PRE: anchor_id must be provided.
# @POST: Thread-local belief state is updated and entry/exit logs are generated.
@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
@@ -71,9 +81,9 @@ def belief_scope(anchor_id: str, message: str = ""):
# Restore old anchor # Restore old anchor
_belief_state.anchor_id = old_anchor _belief_state.anchor_id = old_anchor
# [/DEF:BeliefScope] # [/DEF:belief_scope:Function]
# [DEF:ConfigureLogger:Function] # [DEF:configure_logger:Function]
# @PURPOSE: Configures the logger with the provided logging settings. # @PURPOSE: Configures the logger with the provided logging settings.
# @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.
@@ -115,7 +125,7 @@ def configure_logger(config):
handler.setFormatter(BeliefFormatter( handler.setFormatter(BeliefFormatter(
'[%(asctime)s][%(levelname)s][%(name)s] %(message)s' '[%(asctime)s][%(levelname)s][%(name)s] %(message)s'
)) ))
# [/DEF:ConfigureLogger] # [/DEF:configure_logger:Function]
# [DEF:WebSocketLogHandler:Class] # [DEF:WebSocketLogHandler:Class]
# @SEMANTICS: logging, handler, websocket, buffer # @SEMANTICS: logging, handler, websocket, buffer
@@ -125,12 +135,23 @@ class WebSocketLogHandler(logging.Handler):
A logging handler that stores log records and can be extended to send them A logging handler that stores log records and can be extended to send them
over WebSockets. over WebSockets.
""" """
# [DEF:__init__:Function]
# @PURPOSE: Initializes the handler with a fixed-capacity buffer.
# @PRE: capacity is an integer.
# @POST: Instance initialized with empty deque.
# @PARAM: capacity (int) - Maximum number of logs to keep in memory.
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)
# In a real implementation, you'd have a way to manage active WebSocket connections # In a real implementation, you'd have a way to manage active WebSocket connections
# e.g., self.active_connections: Set[WebSocket] = set() # e.g., self.active_connections: Set[WebSocket] = set()
# [/DEF:__init__:Function]
# [DEF:emit:Function]
# @PURPOSE: Captures a log record, formats it, and stores it in the buffer.
# @PRE: record is a logging.LogRecord.
# @POST: Log is added to the log_buffer.
# @PARAM: record (logging.LogRecord) - The log record to emit.
def emit(self, record: logging.LogRecord): def emit(self, record: logging.LogRecord):
try: try:
log_entry = LogEntry( log_entry = LogEntry(
@@ -151,19 +172,38 @@ class WebSocketLogHandler(logging.Handler):
# Example: for ws in self.active_connections: await ws.send_json(log_entry.dict()) # Example: for ws in self.active_connections: await ws.send_json(log_entry.dict())
except Exception: except Exception:
self.handleError(record) self.handleError(record)
# [/DEF:emit:Function]
# [DEF:get_recent_logs:Function]
# @PURPOSE: Returns a list of recent log entries from the buffer.
# @PRE: None.
# @POST: Returns list of LogEntry objects.
# @RETURN: List[LogEntry] - List of buffered log entries.
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.
""" """
return list(self.log_buffer) return list(self.log_buffer)
# [/DEF:get_recent_logs:Function]
# [/DEF] # [/DEF:WebSocketLogHandler:Class]
# [DEF:Logger:Global] # [DEF:Logger:Global]
# @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.
def believed(anchor_id: str):
def decorator(func):
def wrapper(*args, **kwargs):
with belief_scope(anchor_id):
return func(*args, **kwargs)
return wrapper
return decorator
# [/DEF:believed:Function]
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
# Create a formatter # Create a formatter
@@ -184,4 +224,5 @@ logger.addHandler(websocket_log_handler)
# Example usage: # Example usage:
# logger.info("Application started", extra={"context_key": "context_value"}) # logger.info("Application started", extra={"context_key": "context_value"})
# logger.error("An error occurred", exc_info=True) # logger.error("An error occurred", exc_info=True)
# [/DEF] # [/DEF:Logger:Global]
# [/DEF:LoggerModule:Module]

View File

@@ -23,12 +23,14 @@ import yaml
# @PURPOSE: Engine for transforming Superset export ZIPs. # @PURPOSE: Engine for transforming Superset export ZIPs.
class MigrationEngine: class MigrationEngine:
# [DEF:MigrationEngine.transform_zip:Function] # [DEF:transform_zip:Function]
# @PURPOSE: Extracts ZIP, replaces database UUIDs in YAMLs, and re-packages. # @PURPOSE: Extracts ZIP, replaces database UUIDs in YAMLs, and re-packages.
# @PARAM: zip_path (str) - Path to the source ZIP file. # @PARAM: zip_path (str) - Path to the source ZIP file.
# @PARAM: output_path (str) - Path where the transformed ZIP will be saved. # @PARAM: output_path (str) - Path where the transformed ZIP will be saved.
# @PARAM: db_mapping (Dict[str, str]) - Mapping of source UUID to target UUID. # @PARAM: db_mapping (Dict[str, str]) - Mapping of source UUID to target UUID.
# @PARAM: strip_databases (bool) - Whether to remove the databases directory from the archive. # @PARAM: strip_databases (bool) - Whether to remove the databases directory from the archive.
# @PRE: zip_path must point to a valid Superset export archive.
# @POST: Transformed archive is saved to output_path.
# @RETURN: bool - True if successful. # @RETURN: bool - True if successful.
def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True) -> bool: def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True) -> bool:
""" """
@@ -73,9 +75,14 @@ class MigrationEngine:
except Exception as e: except Exception as e:
logger.error(f"[MigrationEngine.transform_zip][Coherence:Failed] Error transforming ZIP: {e}") logger.error(f"[MigrationEngine.transform_zip][Coherence:Failed] Error transforming ZIP: {e}")
return False return False
# [/DEF:transform_zip:Function]
# [DEF:MigrationEngine._transform_yaml:Function] # [DEF:_transform_yaml:Function]
# @PURPOSE: Replaces database_uuid in a single YAML file. # @PURPOSE: Replaces database_uuid in a single YAML file.
# @PARAM: file_path (Path) - Path to the YAML file.
# @PARAM: db_mapping (Dict[str, str]) - UUID mapping dictionary.
# @PRE: file_path must exist and be readable.
# @POST: File is modified in-place if source UUID matches mapping.
def _transform_yaml(self, file_path: Path, db_mapping: Dict[str, str]): def _transform_yaml(self, file_path: Path, db_mapping: Dict[str, str]):
with open(file_path, 'r') as f: with open(file_path, 'r') as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
@@ -90,8 +97,8 @@ class MigrationEngine:
data['database_uuid'] = db_mapping[source_uuid] data['database_uuid'] = db_mapping[source_uuid]
with open(file_path, 'w') as f: with open(file_path, 'w') as f:
yaml.dump(data, f) yaml.dump(data, f)
# [/DEF:MigrationEngine._transform_yaml] # [/DEF:_transform_yaml:Function]
# [/DEF:MigrationEngine] # [/DEF:MigrationEngine:Class]
# [/DEF:backend.src.core.migration_engine] # [/DEF:backend.src.core.migration_engine:Module]

View File

@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, Any from typing import Dict, Any
from .logger import belief_scope
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -17,44 +18,87 @@ class PluginBase(ABC):
@property @property
@abstractmethod @abstractmethod
# [DEF:id:Function]
# @PURPOSE: Returns the unique identifier for the plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string ID.
# @RETURN: str - Plugin ID.
def id(self) -> str: def id(self) -> str:
"""A unique identifier for the plugin.""" """A unique identifier for the plugin."""
pass with belief_scope("id"):
pass
# [/DEF:id:Function]
@property @property
@abstractmethod @abstractmethod
# [DEF:name:Function]
# @PURPOSE: Returns the human-readable name of the plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string name.
# @RETURN: str - Plugin name.
def name(self) -> str: def name(self) -> str:
"""A human-readable name for the plugin.""" """A human-readable name for the plugin."""
pass with belief_scope("name"):
pass
# [/DEF:name:Function]
@property @property
@abstractmethod @abstractmethod
# [DEF:description:Function]
# @PURPOSE: Returns a brief description of the plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string description.
# @RETURN: str - Plugin description.
def description(self) -> str: def description(self) -> str:
"""A brief description of what the plugin does.""" """A brief description of what the plugin does."""
pass with belief_scope("description"):
pass
# [/DEF:description:Function]
@property @property
@abstractmethod @abstractmethod
# [DEF:version:Function]
# @PURPOSE: Returns the version of the plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string version.
# @RETURN: str - Plugin version.
def version(self) -> str: def version(self) -> str:
"""The version of the plugin.""" """The version of the plugin."""
pass with belief_scope("version"):
pass
# [/DEF:version:Function]
@abstractmethod @abstractmethod
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the plugin's input parameters.
# @PRE: Plugin instance exists.
# @POST: Returns dict schema.
# @RETURN: Dict[str, Any] - JSON schema.
def get_schema(self) -> Dict[str, Any]: def get_schema(self) -> Dict[str, Any]:
""" """
Returns the JSON schema for the plugin's input parameters. Returns the JSON schema for the plugin's input parameters.
This schema will be used to generate the frontend form. This schema will be used to generate the frontend form.
""" """
pass with belief_scope("get_schema"):
pass
# [/DEF:get_schema:Function]
@abstractmethod @abstractmethod
# [DEF:execute:Function]
# @PURPOSE: Executes the plugin's core logic.
# @PARAM: params (Dict[str, Any]) - Validated input parameters.
# @PRE: params must be a dictionary.
# @POST: Plugin execution is completed.
async def execute(self, params: Dict[str, Any]): async def execute(self, params: Dict[str, Any]):
with belief_scope("execute"):
pass
""" """
Executes the plugin's logic. Executes the plugin's logic.
The `params` argument will be validated against the schema returned by `get_schema()`. The `params` argument will be validated against the schema returned by `get_schema()`.
""" """
pass pass
# [/DEF] # [/DEF:execute:Function]
# [/DEF:PluginBase:Class]
# [DEF:PluginConfig:Class] # [DEF:PluginConfig:Class]
# @SEMANTICS: plugin, config, schema, pydantic # @SEMANTICS: plugin, config, schema, pydantic
@@ -68,4 +112,4 @@ class PluginConfig(BaseModel):
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")
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] # [/DEF:PluginConfig:Class]

View File

@@ -4,6 +4,7 @@ import sys # Added this line
from typing import Dict, Type, List, Optional from typing import Dict, Type, List, Optional
from .plugin_base import PluginBase, PluginConfig from .plugin_base import PluginBase, PluginConfig
from jsonschema import validate from jsonschema import validate
from .logger import belief_scope
# [DEF:PluginLoader:Class] # [DEF:PluginLoader:Class]
# @SEMANTICS: plugin, loader, dynamic, import # @SEMANTICS: plugin, loader, dynamic, import
@@ -16,16 +17,28 @@ class PluginLoader:
that inherit from PluginBase. that inherit from PluginBase.
""" """
# [DEF:__init__:Function]
# @PURPOSE: Initializes the PluginLoader with a directory to scan.
# @PRE: plugin_dir is a valid directory path.
# @POST: Plugins are loaded and registered.
# @PARAM: plugin_dir (str) - The directory containing plugin modules.
def __init__(self, plugin_dir: str): def __init__(self, plugin_dir: str):
self.plugin_dir = plugin_dir with belief_scope("__init__"):
self._plugins: Dict[str, PluginBase] = {} self.plugin_dir = plugin_dir
self._plugin_configs: Dict[str, PluginConfig] = {} self._plugins: Dict[str, PluginBase] = {}
self._load_plugins() self._plugin_configs: Dict[str, PluginConfig] = {}
self._load_plugins()
# [/DEF:__init__:Function]
# [DEF:_load_plugins:Function]
# @PURPOSE: Scans the plugin directory and loads all valid plugins.
# @PRE: plugin_dir exists or can be created.
# @POST: _load_module is called for each .py file.
def _load_plugins(self): def _load_plugins(self):
""" with belief_scope("_load_plugins"):
Scans the plugin directory, imports modules, and registers valid plugins. """
""" Scans the plugin directory, imports modules, and registers valid plugins.
"""
if not os.path.exists(self.plugin_dir): if not os.path.exists(self.plugin_dir):
os.makedirs(self.plugin_dir) os.makedirs(self.plugin_dir)
@@ -41,11 +54,19 @@ class PluginLoader:
module_name = filename[:-3] module_name = filename[:-3]
file_path = os.path.join(self.plugin_dir, filename) 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_module:Function]
# @PURPOSE: Loads a single Python module and discovers PluginBase implementations.
# @PRE: module_name and file_path are valid.
# @POST: Plugin classes are instantiated and registered.
# @PARAM: module_name (str) - The name of the module.
# @PARAM: file_path (str) - The path to the module file.
def _load_module(self, module_name: str, file_path: str): def _load_module(self, module_name: str, file_path: str):
""" with belief_scope("_load_module"):
Loads a single Python module and extracts PluginBase subclasses. """
""" Loads a single Python module and extracts PluginBase subclasses.
"""
# Try to determine the correct package prefix based on how the app is running # Try to determine the correct package prefix based on how the app is running
# For standalone execution, we need to handle the import differently # For standalone execution, we need to handle the import differently
if __name__ == "__main__" or "test" in __name__: if __name__ == "__main__" or "test" in __name__:
@@ -83,11 +104,18 @@ class PluginLoader:
self._register_plugin(plugin_instance) self._register_plugin(plugin_instance)
except Exception as e: except Exception as e:
print(f"Error instantiating plugin {attribute_name} in {module_name}: {e}") # Replace with proper logging print(f"Error instantiating plugin {attribute_name} in {module_name}: {e}") # Replace with proper logging
# [/DEF:_load_module:Function]
# [DEF:_register_plugin:Function]
# @PURPOSE: Registers a PluginBase instance and its configuration.
# @PRE: plugin_instance is a valid implementation of PluginBase.
# @POST: Plugin is added to _plugins and _plugin_configs.
# @PARAM: plugin_instance (PluginBase) - The plugin instance to register.
def _register_plugin(self, plugin_instance: PluginBase): def _register_plugin(self, plugin_instance: PluginBase):
""" with belief_scope("_register_plugin"):
Registers a valid plugin instance. """
""" Registers a valid plugin instance.
"""
plugin_id = plugin_instance.id plugin_id = plugin_instance.id
if plugin_id in self._plugins: if plugin_id in self._plugins:
print(f"Warning: Duplicate plugin ID '{plugin_id}' found. Skipping.") # Replace with proper logging print(f"Warning: Duplicate plugin ID '{plugin_id}' found. Skipping.") # Replace with proper logging
@@ -116,22 +144,48 @@ class PluginLoader:
except Exception as e: except Exception as e:
from ..core.logger import logger from ..core.logger import logger
logger.error(f"Error validating plugin '{plugin_instance.name}' (ID: {plugin_id}): {e}") logger.error(f"Error validating plugin '{plugin_instance.name}' (ID: {plugin_id}): {e}")
# [/DEF:_register_plugin:Function]
# [DEF:get_plugin:Function]
# @PURPOSE: Retrieves a loaded plugin instance by its ID.
# @PRE: plugin_id is a string.
# @POST: Returns plugin instance or None.
# @PARAM: plugin_id (str) - The unique identifier of the plugin.
# @RETURN: Optional[PluginBase] - The plugin instance if found, otherwise None.
def get_plugin(self, plugin_id: str) -> Optional[PluginBase]: def get_plugin(self, plugin_id: str) -> Optional[PluginBase]:
""" with belief_scope("get_plugin"):
Returns a loaded plugin instance by its ID. """
""" Returns a loaded plugin instance by its ID.
"""
return self._plugins.get(plugin_id) return self._plugins.get(plugin_id)
# [/DEF:get_plugin:Function]
# [DEF:get_all_plugin_configs:Function]
# @PURPOSE: Returns a list of all registered plugin configurations.
# @PRE: None.
# @POST: Returns list of all PluginConfig objects.
# @RETURN: List[PluginConfig] - A list of plugin configurations.
def get_all_plugin_configs(self) -> List[PluginConfig]: def get_all_plugin_configs(self) -> List[PluginConfig]:
""" with belief_scope("get_all_plugin_configs"):
Returns a list of all loaded plugin configurations. """
""" Returns a list of all loaded plugin configurations.
"""
return list(self._plugin_configs.values()) return list(self._plugin_configs.values())
# [/DEF:get_all_plugin_configs:Function]
# [DEF:has_plugin:Function]
# @PURPOSE: Checks if a plugin with the given ID is registered.
# @PRE: plugin_id is a string.
# @POST: Returns True if plugin exists.
# @PARAM: plugin_id (str) - The unique identifier of the plugin.
# @RETURN: bool - True if the plugin is registered, False otherwise.
def has_plugin(self, plugin_id: str) -> bool: def has_plugin(self, plugin_id: str) -> bool:
""" with belief_scope("has_plugin"):
Checks if a plugin with the given ID is loaded. """
""" Checks if a plugin with the given ID is loaded.
return plugin_id in self._plugins """
return plugin_id in self._plugins
# [/DEF:has_plugin:Function]
# [/DEF:PluginLoader:Class]

View File

@@ -0,0 +1,119 @@
# [DEF:SchedulerModule:Module]
# @SEMANTICS: scheduler, apscheduler, cron, backup
# @PURPOSE: Manages scheduled tasks using APScheduler.
# @LAYER: Core
# @RELATION: Uses TaskManager to run scheduled backups.
# [SECTION: IMPORTS]
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from .logger import logger, belief_scope
from .config_manager import ConfigManager
from typing import Optional
import asyncio
# [/SECTION]
# [DEF:SchedulerService:Class]
# @SEMANTICS: scheduler, service, apscheduler
# @PURPOSE: Provides a service to manage scheduled backup tasks.
class SchedulerService:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the scheduler service with task and config managers.
# @PRE: task_manager and config_manager must be provided.
# @POST: Scheduler instance is created but not started.
def __init__(self, task_manager, config_manager: ConfigManager):
with belief_scope("SchedulerService.__init__"):
self.task_manager = task_manager
self.config_manager = config_manager
self.scheduler = BackgroundScheduler()
self.loop = asyncio.get_event_loop()
# [/DEF:__init__:Function]
# [DEF:start:Function]
# @PURPOSE: Starts the background scheduler and loads initial schedules.
# @PRE: Scheduler should be initialized.
# @POST: Scheduler is running and schedules are loaded.
def start(self):
with belief_scope("SchedulerService.start"):
if not self.scheduler.running:
self.scheduler.start()
logger.info("Scheduler started.")
self.load_schedules()
# [/DEF:start:Function]
# [DEF:stop:Function]
# @PURPOSE: Stops the background scheduler.
# @PRE: Scheduler should be running.
# @POST: Scheduler is shut down.
def stop(self):
with belief_scope("SchedulerService.stop"):
if self.scheduler.running:
self.scheduler.shutdown()
logger.info("Scheduler stopped.")
# [/DEF:stop:Function]
# [DEF:load_schedules:Function]
# @PURPOSE: Loads backup schedules from configuration and registers them.
# @PRE: config_manager must have valid configuration.
# @POST: All enabled backup jobs are added to the scheduler.
def load_schedules(self):
with belief_scope("SchedulerService.load_schedules"):
# Clear existing jobs
self.scheduler.remove_all_jobs()
config = self.config_manager.get_config()
for env in config.environments:
if env.backup_schedule and env.backup_schedule.enabled:
self.add_backup_job(env.id, env.backup_schedule.cron_expression)
# [/DEF:load_schedules:Function]
# [DEF:add_backup_job:Function]
# @PURPOSE: Adds a scheduled backup job for an environment.
# @PRE: env_id and cron_expression must be valid strings.
# @POST: A new job is added to the scheduler or replaced if it already exists.
# @PARAM: env_id (str) - The ID of the environment.
# @PARAM: cron_expression (str) - The cron expression for the schedule.
def add_backup_job(self, env_id: str, cron_expression: str):
with belief_scope("SchedulerService.add_backup_job", f"env_id={env_id}, cron={cron_expression}"):
job_id = f"backup_{env_id}"
try:
self.scheduler.add_job(
self._trigger_backup,
CronTrigger.from_crontab(cron_expression),
id=job_id,
args=[env_id],
replace_existing=True
)
logger.info(f"Scheduled backup job added for environment {env_id}: {cron_expression}")
except Exception as e:
logger.error(f"Failed to add backup job for environment {env_id}: {e}")
# [/DEF:add_backup_job:Function]
# [DEF:_trigger_backup:Function]
# @PURPOSE: Triggered by the scheduler to start a backup task.
# @PRE: env_id must be a valid environment ID.
# @POST: A new backup task is created in the task manager if not already running.
# @PARAM: env_id (str) - The ID of the environment.
def _trigger_backup(self, env_id: str):
with belief_scope("SchedulerService._trigger_backup", f"env_id={env_id}"):
logger.info(f"Triggering scheduled backup for environment {env_id}")
# Check if a backup is already running for this environment
active_tasks = self.task_manager.get_tasks(limit=100)
for task in active_tasks:
if (task.plugin_id == "superset-backup" and
task.status in ["PENDING", "RUNNING"] and
task.params.get("environment_id") == env_id):
logger.warning(f"Backup already running for environment {env_id}. Skipping scheduled run.")
return
# Run the backup task
# We need to run this in the event loop since create_task is async
asyncio.run_coroutine_threadsafe(
self.task_manager.create_task("superset-backup", {"environment_id": env_id}),
self.loop
)
# [/DEF:_trigger_backup:Function]
# [/DEF:SchedulerService:Class]
# [/DEF:SchedulerModule:Module]

View File

@@ -1,83 +1,400 @@
# [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 superset_tool.client import SupersetClient as BaseSupersetClient import zipfile
from superset_tool.models import SupersetConfig from pathlib import Path
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:SupersetClient.get_databases_summary:Function] # @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
# @PURPOSE: Fetch a summary of databases including uuid, name, and engine. # @PRE: `env` должен быть валидным объектом Environment.
# @POST: Returns a list of database dictionaries with 'engine' field. # @POST: Атрибуты `env` и `network` созданы и готовы к работе.
# @RETURN: List[Dict] - Summary of databases. # @PARAM: env (Environment) - Конфигурация окружения.
def get_databases_summary(self) -> List[Dict]: def __init__(self, env: Environment):
""" with belief_scope("__init__"):
Fetch a summary of databases including uuid, name, and engine. app_logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient for env %s.", env.name)
""" self.env = env
query = { # Construct auth payload expected by Superset API
"columns": ["uuid", "database_name", "backend"] auth_payload = {
"username": env.username,
"password": env.password,
"provider": "db",
"refresh": "true"
} }
_, databases = self.get_databases(query=query) self.network = APIClient(
config={
# Map 'backend' to 'engine' for consistency with contracts "base_url": env.url,
for db in databases: "auth": auth_payload
db['engine'] = db.pop('backend', None) },
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:authenticate:Function]
# @PURPOSE: Authenticates the client using the configured credentials.
# @PRE: self.network must be initialized with valid auth configuration.
# @POST: Client is authenticated and tokens are stored.
# @RETURN: Dict[str, str] - Authentication tokens.
def authenticate(self) -> Dict[str, str]:
with belief_scope("SupersetClient.authenticate"):
return self.network.authenticate()
# [/DEF:authenticate:Function]
@property
# [DEF:headers:Function]
# @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
def headers(self) -> dict:
with belief_scope("headers"):
return self.network.headers
# [/DEF:headers:Function]
# [SECTION: DASHBOARD OPERATIONS]
# [DEF:get_dashboards:Function]
# @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса для API.
# @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"]
return databases total_count = self._fetch_total_object_count(endpoint="/dashboard/")
# [/DEF:SupersetClient.get_databases_summary] 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:SupersetClient.get_database_by_uuid:Function] # [DEF:get_dashboards_summary:Function]
# @PURPOSE: Find a database by its UUID.
# @PARAM: db_uuid (str) - The UUID of the database.
# @RETURN: Optional[Dict] - Database info if found, else None.
def get_database_by_uuid(self, db_uuid: str) -> Optional[Dict]:
"""
Find a database by its UUID.
"""
query = {
"filters": [{"col": "uuid", "op": "eq", "value": db_uuid}]
}
_, databases = self.get_databases(query=query)
return databases[0] if databases else None
# [/DEF:SupersetClient.get_database_by_uuid]
# [DEF:SupersetClient.get_dashboards_summary:Function]
# @PURPOSE: Fetches dashboard metadata optimized for the grid. # @PURPOSE: Fetches dashboard metadata optimized for the grid.
# @POST: Returns a list of dashboard dictionaries. # @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"):
Fetches dashboard metadata optimized for the grid. query = {
Returns a list of dictionaries mapped to DashboardMetadata fields. "columns": ["id", "dashboard_title", "changed_on_utc", "published"]
""" }
query = { _, dashboards = self.get_dashboards(query=query)
"columns": ["id", "dashboard_title", "changed_on_utc", "published"]
}
_, dashboards = self.get_dashboards(query=query)
# Map fields to DashboardMetadata schema # Map fields to DashboardMetadata schema
result = [] result = []
for dash in dashboards: for dash in dashboards:
result.append({ result.append({
"id": dash.get("id"), "id": dash.get("id"),
"title": dash.get("dashboard_title"), "title": dash.get("dashboard_title"),
"last_modified": dash.get("changed_on_utc"), "last_modified": dash.get("changed_on_utc"),
"status": "published" if dash.get("published") else "draft" "status": "published" if dash.get("published") else "draft"
}) })
return result return result
# [/DEF:SupersetClient.get_dashboards_summary] # [/DEF:get_dashboards_summary:Function]
# [/DEF:SupersetClient] # [DEF:export_dashboard:Function]
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
# @PARAM: dashboard_id (int) - ID дашборда для экспорта.
# @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:backend.src.core.superset_client] # [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.
# @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 дашборда.
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]) - Дополнительные параметры запроса.
# @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]
# @PURPOSE: Получает информацию о конкретном датасете по его ID.
# @PARAM: dataset_id (int) - ID датасета.
# @RETURN: Dict - Информация о датасете.
def get_dataset(self, dataset_id: int) -> Dict:
with belief_scope("SupersetClient.get_dataset", f"id={dataset_id}"):
app_logger.info("[get_dataset][Enter] Fetching dataset %s.", dataset_id)
response = self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}")
response = cast(Dict, response)
app_logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id)
return response
# [/DEF:get_dataset:Function]
# [DEF:update_dataset:Function]
# @PURPOSE: Обновляет данные датасета по его ID.
# @PARAM: dataset_id (int) - ID датасета.
# @PARAM: data (Dict) - Данные для обновления.
# @RETURN: Dict - Ответ API.
def update_dataset(self, dataset_id: int, data: Dict) -> Dict:
with belief_scope("SupersetClient.update_dataset", f"id={dataset_id}"):
app_logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id)
response = self.network.request(
method="PUT",
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]
# [/SECTION]
# [SECTION: DATABASE OPERATIONS]
# [DEF:get_databases:Function]
# @PURPOSE: Получает полный список баз данных.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @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 базы данных.
# @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.
# @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.
# @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]
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]
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]
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]
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]
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]
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]
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]
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:backend.src.core.superset_client:Module]

View File

@@ -0,0 +1,47 @@
# [DEF:TaskCleanupModule:Module]
# @SEMANTICS: task, cleanup, retention
# @PURPOSE: Implements task cleanup and retention policies.
# @LAYER: Core
# @RELATION: Uses TaskPersistenceService to delete old tasks.
from datetime import datetime, timedelta
from .persistence import TaskPersistenceService
from ..logger import logger, belief_scope
from ..config_manager import ConfigManager
# [DEF:TaskCleanupService:Class]
# @PURPOSE: Provides methods to clean up old task records.
class TaskCleanupService:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the cleanup service with dependencies.
# @PRE: persistence_service and config_manager are valid.
# @POST: Cleanup service is ready.
def __init__(self, persistence_service: TaskPersistenceService, config_manager: ConfigManager):
self.persistence_service = persistence_service
self.config_manager = config_manager
# [/DEF:__init__:Function]
# [DEF:run_cleanup:Function]
# @PURPOSE: Deletes tasks older than the configured retention period.
# @PRE: Config manager has valid settings.
# @POST: Old tasks are deleted from persistence.
def run_cleanup(self):
with belief_scope("TaskCleanupService.run_cleanup"):
settings = self.config_manager.get_config().settings
retention_days = settings.task_retention_days
# This is a simplified implementation.
# In a real scenario, we would query IDs of tasks older than retention_days.
# For now, we'll log the action.
logger.info(f"Cleaning up tasks older than {retention_days} days.")
# Re-loading tasks to check for limit
tasks = self.persistence_service.load_tasks(limit=1000)
if len(tasks) > settings.task_retention_limit:
to_delete = [t.id for t in tasks[settings.task_retention_limit:]]
self.persistence_service.delete_tasks(to_delete)
logger.info(f"Deleted {len(to_delete)} tasks exceeding limit of {settings.task_retention_limit}")
# [/DEF:run_cleanup:Function]
# [/DEF:TaskCleanupService:Class]
# [/DEF:TaskCleanupModule:Module]

View File

@@ -25,7 +25,7 @@ class TaskManager:
Manages the lifecycle of tasks, including their creation, execution, and state tracking. Manages the lifecycle of tasks, including their creation, execution, and state tracking.
""" """
# [DEF:TaskManager.__init__:Function] # [DEF:__init__:Function]
# @PURPOSE: Initialize the TaskManager with dependencies. # @PURPOSE: Initialize the TaskManager with dependencies.
# @PRE: plugin_loader is initialized. # @PRE: plugin_loader is initialized.
# @POST: TaskManager is ready to accept tasks. # @POST: TaskManager is ready to accept tasks.
@@ -46,9 +46,9 @@ class TaskManager:
# Load persisted tasks on startup # Load persisted tasks on startup
self.load_persisted_tasks() self.load_persisted_tasks()
# [/DEF:TaskManager.__init__:Function] # [/DEF:__init__:Function]
# [DEF:TaskManager.create_task:Function] # [DEF:create_task:Function]
# @PURPOSE: Creates and queues a new task for execution. # @PURPOSE: Creates and queues a new task for execution.
# @PRE: Plugin with plugin_id exists. Params are valid. # @PRE: Plugin with plugin_id exists. Params are valid.
# @POST: Task is created, added to registry, and scheduled for execution. # @POST: Task is created, added to registry, and scheduled for execution.
@@ -71,12 +71,13 @@ class TaskManager:
task = Task(plugin_id=plugin_id, params=params, user_id=user_id) task = Task(plugin_id=plugin_id, params=params, user_id=user_id)
self.tasks[task.id] = task self.tasks[task.id] = task
self.persistence_service.persist_task(task)
logger.info(f"Task {task.id} created and scheduled for execution") logger.info(f"Task {task.id} created and scheduled for execution")
self.loop.create_task(self._run_task(task.id)) # Schedule task for execution self.loop.create_task(self._run_task(task.id)) # Schedule task for execution
return task return task
# [/DEF:TaskManager.create_task:Function] # [/DEF:create_task:Function]
# [DEF:TaskManager._run_task:Function] # [DEF:_run_task:Function]
# @PURPOSE: Internal method to execute a task. # @PURPOSE: Internal method to execute a task.
# @PRE: Task exists in registry. # @PRE: Task exists in registry.
# @POST: Task is executed, status updated to SUCCESS or FAILED. # @POST: Task is executed, status updated to SUCCESS or FAILED.
@@ -89,6 +90,7 @@ class TaskManager:
logger.info(f"Starting execution of task {task_id} for plugin '{plugin.name}'") logger.info(f"Starting execution of task {task_id} for plugin '{plugin.name}'")
task.status = TaskStatus.RUNNING task.status = TaskStatus.RUNNING
task.started_at = datetime.utcnow() task.started_at = datetime.utcnow()
self.persistence_service.persist_task(task)
self._add_log(task_id, "INFO", f"Task started for plugin '{plugin.name}'") self._add_log(task_id, "INFO", f"Task started for plugin '{plugin.name}'")
try: try:
@@ -96,9 +98,9 @@ class TaskManager:
params = {**task.params, "_task_id": task_id} params = {**task.params, "_task_id": task_id}
if asyncio.iscoroutinefunction(plugin.execute): if asyncio.iscoroutinefunction(plugin.execute):
await plugin.execute(params) task.result = await plugin.execute(params)
else: else:
await self.loop.run_in_executor( task.result = await self.loop.run_in_executor(
self.executor, self.executor,
plugin.execute, plugin.execute,
params params
@@ -113,10 +115,11 @@ class TaskManager:
self._add_log(task_id, "ERROR", f"Task failed: {e}", {"error_type": type(e).__name__}) self._add_log(task_id, "ERROR", f"Task failed: {e}", {"error_type": type(e).__name__})
finally: finally:
task.finished_at = datetime.utcnow() task.finished_at = datetime.utcnow()
self.persistence_service.persist_task(task)
logger.info(f"Task {task_id} execution finished with status: {task.status}") logger.info(f"Task {task_id} execution finished with status: {task.status}")
# [/DEF:TaskManager._run_task:Function] # [/DEF:_run_task:Function]
# [DEF:TaskManager.resolve_task:Function] # [DEF:resolve_task:Function]
# @PURPOSE: Resumes a task that is awaiting mapping. # @PURPOSE: Resumes a task that is awaiting mapping.
# @PRE: Task exists and is in AWAITING_MAPPING state. # @PRE: Task exists and is in AWAITING_MAPPING state.
# @POST: Task status updated to RUNNING, params updated, execution resumed. # @POST: Task status updated to RUNNING, params updated, execution resumed.
@@ -132,14 +135,15 @@ class TaskManager:
# Update task params with resolution # Update task params with resolution
task.params.update(resolution_params) task.params.update(resolution_params)
task.status = TaskStatus.RUNNING task.status = TaskStatus.RUNNING
self.persistence_service.persist_task(task)
self._add_log(task_id, "INFO", "Task resumed after mapping resolution.") self._add_log(task_id, "INFO", "Task resumed after mapping resolution.")
# Signal the future to continue # Signal the future to continue
if task_id in self.task_futures: if task_id in self.task_futures:
self.task_futures[task_id].set_result(True) self.task_futures[task_id].set_result(True)
# [/DEF:TaskManager.resolve_task:Function] # [/DEF:resolve_task:Function]
# [DEF:TaskManager.wait_for_resolution:Function] # [DEF:wait_for_resolution:Function]
# @PURPOSE: Pauses execution and waits for a resolution signal. # @PURPOSE: Pauses execution and waits for a resolution signal.
# @PRE: Task exists. # @PRE: Task exists.
# @POST: Execution pauses until future is set. # @POST: Execution pauses until future is set.
@@ -150,6 +154,7 @@ class TaskManager:
if not task: return if not task: return
task.status = TaskStatus.AWAITING_MAPPING task.status = TaskStatus.AWAITING_MAPPING
self.persistence_service.persist_task(task)
self.task_futures[task_id] = self.loop.create_future() self.task_futures[task_id] = self.loop.create_future()
try: try:
@@ -157,9 +162,9 @@ class TaskManager:
finally: finally:
if task_id in self.task_futures: if task_id in self.task_futures:
del self.task_futures[task_id] del self.task_futures[task_id]
# [/DEF:TaskManager.wait_for_resolution:Function] # [/DEF:wait_for_resolution:Function]
# [DEF:TaskManager.wait_for_input:Function] # [DEF:wait_for_input:Function]
# @PURPOSE: Pauses execution and waits for user input. # @PURPOSE: Pauses execution and waits for user input.
# @PRE: Task exists. # @PRE: Task exists.
# @POST: Execution pauses until future is set via resume_task_with_password. # @POST: Execution pauses until future is set via resume_task_with_password.
@@ -177,24 +182,30 @@ class TaskManager:
finally: finally:
if task_id in self.task_futures: if task_id in self.task_futures:
del self.task_futures[task_id] del self.task_futures[task_id]
# [/DEF:TaskManager.wait_for_input:Function] # [/DEF:wait_for_input:Function]
# [DEF:TaskManager.get_task:Function] # [DEF:get_task:Function]
# @PURPOSE: Retrieves a task by its ID. # @PURPOSE: Retrieves a task by its ID.
# @PRE: task_id is a string.
# @POST: Returns Task object or None.
# @PARAM: task_id (str) - ID of the task. # @PARAM: task_id (str) - ID of the task.
# @RETURN: Optional[Task] - The task or None. # @RETURN: Optional[Task] - The task or None.
def get_task(self, task_id: str) -> Optional[Task]: def get_task(self, task_id: str) -> Optional[Task]:
return self.tasks.get(task_id) with belief_scope("TaskManager.get_task", f"task_id={task_id}"):
# [/DEF:TaskManager.get_task:Function] return self.tasks.get(task_id)
# [/DEF:get_task:Function]
# [DEF:TaskManager.get_all_tasks:Function] # [DEF:get_all_tasks:Function]
# @PURPOSE: Retrieves all registered tasks. # @PURPOSE: Retrieves all registered tasks.
# @PRE: None.
# @POST: Returns list of all Task objects.
# @RETURN: List[Task] - All tasks. # @RETURN: List[Task] - All tasks.
def get_all_tasks(self) -> List[Task]: def get_all_tasks(self) -> List[Task]:
return list(self.tasks.values()) with belief_scope("TaskManager.get_all_tasks"):
# [/DEF:TaskManager.get_all_tasks:Function] return list(self.tasks.values())
# [/DEF:get_all_tasks:Function]
# [DEF:TaskManager.get_tasks:Function] # [DEF:get_tasks:Function]
# @PURPOSE: Retrieves tasks with pagination and optional status filter. # @PURPOSE: Retrieves tasks with pagination and optional status filter.
# @PRE: limit and offset are non-negative integers. # @PRE: limit and offset are non-negative integers.
# @POST: Returns a list of tasks sorted by start_time descending. # @POST: Returns a list of tasks sorted by start_time descending.
@@ -203,24 +214,28 @@ class TaskManager:
# @PARAM: status (Optional[TaskStatus]) - Filter by task status. # @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @RETURN: List[Task] - List of tasks matching criteria. # @RETURN: List[Task] - List of tasks matching criteria.
def get_tasks(self, limit: int = 10, offset: int = 0, status: Optional[TaskStatus] = None) -> List[Task]: def get_tasks(self, limit: int = 10, offset: int = 0, status: Optional[TaskStatus] = None) -> List[Task]:
tasks = list(self.tasks.values()) with belief_scope("TaskManager.get_tasks"):
tasks = list(self.tasks.values())
if status: if status:
tasks = [t for t in tasks if t.status == status] tasks = [t for t in tasks if t.status == status]
# Sort by start_time descending (most recent first) # Sort by start_time descending (most recent first)
tasks.sort(key=lambda t: t.started_at or datetime.min, reverse=True) tasks.sort(key=lambda t: t.started_at or datetime.min, reverse=True)
return tasks[offset:offset + limit] return tasks[offset:offset + limit]
# [/DEF:TaskManager.get_tasks:Function] # [/DEF:get_tasks:Function]
# [DEF:TaskManager.get_task_logs:Function] # [DEF:get_task_logs:Function]
# @PURPOSE: Retrieves logs for a specific task. # @PURPOSE: Retrieves logs for a specific task.
# @PRE: task_id is a string.
# @POST: Returns list of LogEntry objects.
# @PARAM: task_id (str) - ID of the task. # @PARAM: task_id (str) - ID of the task.
# @RETURN: List[LogEntry] - List of log entries. # @RETURN: List[LogEntry] - List of log entries.
def get_task_logs(self, task_id: str) -> List[LogEntry]: def get_task_logs(self, task_id: str) -> List[LogEntry]:
task = self.tasks.get(task_id) with belief_scope("TaskManager.get_task_logs", f"task_id={task_id}"):
return task.logs if task else [] task = self.tasks.get(task_id)
# [/DEF:TaskManager.get_task_logs:Function] return task.logs if task else []
# [/DEF:get_task_logs:Function]
# [DEF:TaskManager._add_log:Function] # [DEF:_add_log:Function]
# @PURPOSE: Adds a log entry to a task and notifies subscribers. # @PURPOSE: Adds a log entry to a task and notifies subscribers.
# @PRE: Task exists. # @PRE: Task exists.
# @POST: Log added to task and pushed to queues. # @POST: Log added to task and pushed to queues.
@@ -229,59 +244,64 @@ class TaskManager:
# @PARAM: message (str) - Log message. # @PARAM: message (str) - Log message.
# @PARAM: context (Optional[Dict]) - Log context. # @PARAM: context (Optional[Dict]) - Log context.
def _add_log(self, task_id: str, level: str, message: str, context: Optional[Dict[str, Any]] = None): def _add_log(self, task_id: str, level: str, message: str, context: Optional[Dict[str, Any]] = None):
task = self.tasks.get(task_id) with belief_scope("TaskManager._add_log", f"task_id={task_id}"):
if not task: task = self.tasks.get(task_id)
return if not task:
return
log_entry = LogEntry(level=level, message=message, context=context) log_entry = LogEntry(level=level, message=message, context=context)
task.logs.append(log_entry) task.logs.append(log_entry)
self.persistence_service.persist_task(task)
# Notify subscribers # Notify subscribers
if task_id in self.subscribers: if task_id in self.subscribers:
for queue in self.subscribers[task_id]: for queue in self.subscribers[task_id]:
self.loop.call_soon_threadsafe(queue.put_nowait, log_entry) self.loop.call_soon_threadsafe(queue.put_nowait, log_entry)
# [/DEF:TaskManager._add_log:Function] # [/DEF:_add_log:Function]
# [DEF:TaskManager.subscribe_logs:Function] # [DEF:subscribe_logs:Function]
# @PURPOSE: Subscribes to real-time logs for a task. # @PURPOSE: Subscribes to real-time logs for a task.
# @PRE: task_id is a string.
# @POST: Returns an asyncio.Queue for log entries.
# @PARAM: task_id (str) - ID of the task. # @PARAM: task_id (str) - ID of the task.
# @RETURN: asyncio.Queue - Queue for log entries. # @RETURN: asyncio.Queue - Queue for log entries.
async def subscribe_logs(self, task_id: str) -> asyncio.Queue: async def subscribe_logs(self, task_id: str) -> asyncio.Queue:
queue = asyncio.Queue() with belief_scope("TaskManager.subscribe_logs", f"task_id={task_id}"):
if task_id not in self.subscribers: queue = asyncio.Queue()
self.subscribers[task_id] = [] if task_id not in self.subscribers:
self.subscribers[task_id].append(queue) self.subscribers[task_id] = []
return queue self.subscribers[task_id].append(queue)
# [/DEF:TaskManager.subscribe_logs:Function] return queue
# [/DEF:subscribe_logs:Function]
# [DEF:TaskManager.unsubscribe_logs:Function] # [DEF:unsubscribe_logs:Function]
# @PURPOSE: Unsubscribes from real-time logs for a task. # @PURPOSE: Unsubscribes from real-time logs for a task.
# @PRE: task_id is a string, queue is asyncio.Queue.
# @POST: Queue removed from subscribers.
# @PARAM: task_id (str) - ID of the task. # @PARAM: task_id (str) - ID of the task.
# @PARAM: queue (asyncio.Queue) - Queue to remove. # @PARAM: queue (asyncio.Queue) - Queue to remove.
def unsubscribe_logs(self, task_id: str, queue: asyncio.Queue): def unsubscribe_logs(self, task_id: str, queue: asyncio.Queue):
if task_id in self.subscribers: with belief_scope("TaskManager.unsubscribe_logs", f"task_id={task_id}"):
if queue in self.subscribers[task_id]: if task_id in self.subscribers:
self.subscribers[task_id].remove(queue) if queue in self.subscribers[task_id]:
if not self.subscribers[task_id]: self.subscribers[task_id].remove(queue)
del self.subscribers[task_id] if not self.subscribers[task_id]:
# [/DEF:TaskManager.unsubscribe_logs:Function] del self.subscribers[task_id]
# [/DEF:unsubscribe_logs:Function]
# [DEF:TaskManager.persist_awaiting_input_tasks:Function] # [DEF:load_persisted_tasks:Function]
# @PURPOSE: Persist tasks in AWAITING_INPUT state using persistence service.
def persist_awaiting_input_tasks(self) -> None:
self.persistence_service.persist_tasks(list(self.tasks.values()))
# [/DEF:TaskManager.persist_awaiting_input_tasks:Function]
# [DEF:TaskManager.load_persisted_tasks:Function]
# @PURPOSE: Load persisted tasks using persistence service. # @PURPOSE: Load persisted tasks using persistence service.
# @PRE: None.
# @POST: Persisted tasks loaded into self.tasks.
def load_persisted_tasks(self) -> None: def load_persisted_tasks(self) -> None:
loaded_tasks = self.persistence_service.load_tasks() with belief_scope("TaskManager.load_persisted_tasks"):
for task in loaded_tasks: loaded_tasks = self.persistence_service.load_tasks(limit=100)
if task.id not in self.tasks: for task in loaded_tasks:
self.tasks[task.id] = task if task.id not in self.tasks:
# [/DEF:TaskManager.load_persisted_tasks:Function] self.tasks[task.id] = task
# [/DEF:load_persisted_tasks:Function]
# [DEF:TaskManager.await_input:Function] # [DEF:await_input:Function]
# @PURPOSE: Transition a task to AWAITING_INPUT state with input request. # @PURPOSE: Transition a task to AWAITING_INPUT state with input request.
# @PRE: Task exists and is in RUNNING state. # @PRE: Task exists and is in RUNNING state.
# @POST: Task status changed to AWAITING_INPUT, input_request set, persisted. # @POST: Task status changed to AWAITING_INPUT, input_request set, persisted.
@@ -299,12 +319,11 @@ class TaskManager:
task.status = TaskStatus.AWAITING_INPUT task.status = TaskStatus.AWAITING_INPUT
task.input_required = True task.input_required = True
task.input_request = input_request task.input_request = input_request
self.persistence_service.persist_task(task)
self._add_log(task_id, "INFO", "Task paused for user input", {"input_request": input_request}) self._add_log(task_id, "INFO", "Task paused for user input", {"input_request": input_request})
# [/DEF:await_input:Function]
self.persist_awaiting_input_tasks()
# [/DEF:TaskManager.await_input:Function]
# [DEF:TaskManager.resume_task_with_password:Function] # [DEF:resume_task_with_password:Function]
# @PURPOSE: Resume a task that is awaiting input with provided passwords. # @PURPOSE: Resume a task that is awaiting input with provided passwords.
# @PRE: Task exists and is in AWAITING_INPUT state. # @PRE: Task exists and is in AWAITING_INPUT state.
# @POST: Task status changed to RUNNING, passwords injected, task resumed. # @POST: Task status changed to RUNNING, passwords injected, task resumed.
@@ -326,17 +345,17 @@ class TaskManager:
task.input_required = False task.input_required = False
task.input_request = None task.input_request = None
task.status = TaskStatus.RUNNING task.status = TaskStatus.RUNNING
self.persistence_service.persist_task(task)
self._add_log(task_id, "INFO", "Task resumed with passwords", {"databases": list(passwords.keys())}) self._add_log(task_id, "INFO", "Task resumed with passwords", {"databases": list(passwords.keys())})
if task_id in self.task_futures: if task_id in self.task_futures:
self.task_futures[task_id].set_result(True) self.task_futures[task_id].set_result(True)
# [/DEF:resume_task_with_password:Function]
# Remove from persistence as it's no longer awaiting input
self.persistence_service.delete_tasks([task_id])
# [/DEF:TaskManager.resume_task_with_password:Function]
# [DEF:TaskManager.clear_tasks:Function] # [DEF:clear_tasks:Function]
# @PURPOSE: Clears tasks based on status filter. # @PURPOSE: Clears tasks based on status filter.
# @PRE: status is Optional[TaskStatus].
# @POST: Tasks matching filter (or all non-active) cleared from registry and database.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status. # @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @RETURN: int - Number of tasks cleared. # @RETURN: int - Number of tasks cleared.
def clear_tasks(self, status: Optional[TaskStatus] = None) -> int: def clear_tasks(self, status: Optional[TaskStatus] = None) -> int:
@@ -373,7 +392,7 @@ class TaskManager:
logger.info(f"Cleared {len(tasks_to_remove)} tasks.") logger.info(f"Cleared {len(tasks_to_remove)} tasks.")
return len(tasks_to_remove) return len(tasks_to_remove)
# [/DEF:TaskManager.clear_tasks:Function] # [/DEF:clear_tasks:Function]
# [/DEF:TaskManager:Class] # [/DEF:TaskManager:Class]
# [/DEF:TaskManagerModule:Module] # [/DEF:TaskManagerModule:Module]

View File

@@ -51,8 +51,9 @@ class Task(BaseModel):
params: Dict[str, Any] = Field(default_factory=dict) params: Dict[str, Any] = Field(default_factory=dict)
input_required: bool = False input_required: bool = False
input_request: Optional[Dict[str, Any]] = None input_request: Optional[Dict[str, Any]] = None
result: Optional[Dict[str, Any]] = None
# [DEF:Task.__init__:Function] # [DEF:__init__:Function]
# @PURPOSE: Initializes the Task model and validates input_request for AWAITING_INPUT status. # @PURPOSE: Initializes the Task model and validates input_request for AWAITING_INPUT status.
# @PRE: If status is AWAITING_INPUT, input_request must be provided. # @PRE: If status is AWAITING_INPUT, input_request must be provided.
# @POST: Task instance is created or ValueError is raised. # @POST: Task instance is created or ValueError is raised.
@@ -61,7 +62,7 @@ class Task(BaseModel):
super().__init__(**data) super().__init__(**data)
if self.status == TaskStatus.AWAITING_INPUT and not self.input_request: if self.status == TaskStatus.AWAITING_INPUT and not self.input_request:
raise ValueError("input_request is required when status is AWAITING_INPUT") raise ValueError("input_request is required when status is AWAITING_INPUT")
# [/DEF:Task.__init__:Function] # [/DEF:__init__:Function]
# [/DEF:Task:Class] # [/DEF:Task:Class]
# [/DEF:TaskManagerModels:Module] # [/DEF:TaskManagerModels:Module]

View File

@@ -1,158 +1,158 @@
# [DEF:TaskPersistenceModule:Module] # [DEF:TaskPersistenceModule:Module]
# @SEMANTICS: persistence, sqlite, task, storage # @SEMANTICS: persistence, sqlite, sqlalchemy, task, storage
# @PURPOSE: Handles the persistence of tasks, specifically those awaiting user input, to a SQLite database. # @PURPOSE: Handles the persistence of tasks using SQLAlchemy and the tasks.db database.
# @LAYER: Core # @LAYER: Core
# @RELATION: Used by TaskManager to save and load tasks. # @RELATION: Used by TaskManager to save and load tasks.
# @INVARIANT: Database schema must match the Task model structure. # @INVARIANT: Database schema must match the TaskRecord model structure.
# @CONSTRAINT: Uses synchronous SQLite operations (blocking), should be used carefully.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
import sqlite3
import json
from datetime import datetime from datetime import datetime
from pathlib import Path from typing import List, Optional, Dict, Any
from typing import Dict, List, Optional, Any import json
from .models import Task, TaskStatus from sqlalchemy.orm import Session
from ...models.task import TaskRecord
from ..database import TasksSessionLocal
from .models import Task, TaskStatus, LogEntry
from ..logger import logger, belief_scope from ..logger import logger, belief_scope
# [/SECTION] # [/SECTION]
# [DEF:TaskPersistenceService:Class] # [DEF:TaskPersistenceService:Class]
# @SEMANTICS: persistence, service, database # @SEMANTICS: persistence, service, database, sqlalchemy
# @PURPOSE: Provides methods to save and load tasks from a local SQLite database. # @PURPOSE: Provides methods to save and load tasks from the tasks.db database using SQLAlchemy.
class TaskPersistenceService: class TaskPersistenceService:
def __init__(self, db_path: Optional[Path] = None): # [DEF:__init__:Function]
if db_path is None: # @PURPOSE: Initializes the persistence service.
self.db_path = Path(__file__).parent.parent.parent.parent / "migrations.db"
else:
self.db_path = db_path
self._ensure_db_exists()
# [DEF:TaskPersistenceService._ensure_db_exists:Function]
# @PURPOSE: Ensures the database directory and table exist.
# @PRE: None. # @PRE: None.
# @POST: Database file and table are created if they didn't exist. # @POST: Service is ready.
def _ensure_db_exists(self) -> None: def __init__(self):
with belief_scope("TaskPersistenceService._ensure_db_exists"): with belief_scope("TaskPersistenceService.__init__"):
self.db_path.parent.mkdir(parents=True, exist_ok=True) # We use TasksSessionLocal from database.py
pass
conn = sqlite3.connect(str(self.db_path)) # [/DEF:__init__:Function]
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS persistent_tasks (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
input_request JSON,
context JSON
)
""")
conn.commit()
conn.close()
# [/DEF:TaskPersistenceService._ensure_db_exists:Function]
# [DEF:TaskPersistenceService.persist_tasks:Function] # [DEF:persist_task:Function]
# @PURPOSE: Persists a list of tasks to the database. # @PURPOSE: Persists or updates a single task in the database.
# @PRE: Tasks list contains valid Task objects. # @PRE: isinstance(task, Task)
# @POST: Tasks matching the criteria (AWAITING_INPUT) are saved/updated in the DB. # @POST: Task record created or updated in database.
# @PARAM: tasks (List[Task]) - The list of tasks to check and persist. # @PARAM: task (Task) - The task object to persist.
def persist_task(self, task: Task) -> None:
with belief_scope("TaskPersistenceService.persist_task", f"task_id={task.id}"):
session: Session = TasksSessionLocal()
try:
record = session.query(TaskRecord).filter(TaskRecord.id == task.id).first()
if not record:
record = TaskRecord(id=task.id)
session.add(record)
record.type = task.plugin_id
record.status = task.status.value
record.environment_id = task.params.get("environment_id") or task.params.get("source_env_id")
record.started_at = task.started_at
record.finished_at = task.finished_at
record.params = task.params
record.result = task.result
# Store logs as JSON, converting datetime to string
record.logs = []
for log in task.logs:
log_dict = log.dict()
if isinstance(log_dict.get('timestamp'), datetime):
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
record.logs.append(log_dict)
# Extract error if failed
if task.status == TaskStatus.FAILED:
for log in reversed(task.logs):
if log.level == "ERROR":
record.error = log.message
break
session.commit()
except Exception as e:
session.rollback()
logger.error(f"Failed to persist task {task.id}: {e}")
finally:
session.close()
# [/DEF:persist_task:Function]
# [DEF:persist_tasks:Function]
# @PURPOSE: Persists multiple tasks.
# @PRE: isinstance(tasks, list)
# @POST: All tasks in list are persisted.
# @PARAM: tasks (List[Task]) - The list of tasks to persist.
def persist_tasks(self, tasks: List[Task]) -> None: def persist_tasks(self, tasks: List[Task]) -> None:
with belief_scope("TaskPersistenceService.persist_tasks"): with belief_scope("TaskPersistenceService.persist_tasks"):
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
count = 0
for task in tasks: for task in tasks:
if task.status == TaskStatus.AWAITING_INPUT: self.persist_task(task)
cursor.execute(""" # [/DEF:persist_tasks:Function]
INSERT OR REPLACE INTO persistent_tasks
(id, plugin_id, status, created_at, updated_at, input_request, context)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
task.id,
task.plugin_id,
task.status.value,
task.started_at.isoformat() if task.started_at else datetime.utcnow().isoformat(),
datetime.utcnow().isoformat(),
json.dumps(task.input_request) if task.input_request else None,
json.dumps(task.params)
))
count += 1
conn.commit()
conn.close()
logger.info(f"Persisted {count} tasks awaiting input.")
# [/DEF:TaskPersistenceService.persist_tasks:Function]
# [DEF:TaskPersistenceService.load_tasks:Function] # [DEF:load_tasks:Function]
# @PURPOSE: Loads persisted tasks from the database. # @PURPOSE: Loads tasks from the database.
# @PRE: Database exists. # @PRE: limit is an integer.
# @POST: Returns a list of Task objects reconstructed from the DB. # @POST: Returns list of Task objects.
# @PARAM: limit (int) - Max tasks to load.
# @PARAM: status (Optional[TaskStatus]) - Filter by status.
# @RETURN: List[Task] - The loaded tasks. # @RETURN: List[Task] - The loaded tasks.
def load_tasks(self) -> List[Task]: def load_tasks(self, limit: int = 100, status: Optional[TaskStatus] = None) -> List[Task]:
with belief_scope("TaskPersistenceService.load_tasks"): with belief_scope("TaskPersistenceService.load_tasks"):
if not self.db_path.exists(): session: Session = TasksSessionLocal()
return [] try:
query = session.query(TaskRecord)
conn = sqlite3.connect(str(self.db_path)) if status:
cursor = conn.cursor() query = query.filter(TaskRecord.status == status.value)
# Check if plugin_id column exists (migration for existing db) records = query.order_by(TaskRecord.created_at.desc()).limit(limit).all()
cursor.execute("PRAGMA table_info(persistent_tasks)")
columns = [info[1] for info in cursor.fetchall()] loaded_tasks = []
has_plugin_id = "plugin_id" in columns for record in records:
try:
logs = []
if record.logs:
for log_data in record.logs:
# Handle timestamp conversion if it's a string
if isinstance(log_data.get('timestamp'), str):
log_data['timestamp'] = datetime.fromisoformat(log_data['timestamp'])
logs.append(LogEntry(**log_data))
if has_plugin_id: task = Task(
cursor.execute("SELECT id, plugin_id, status, created_at, input_request, context FROM persistent_tasks") id=record.id,
else: plugin_id=record.type,
cursor.execute("SELECT id, status, created_at, input_request, context FROM persistent_tasks") status=TaskStatus(record.status),
started_at=record.started_at,
rows = cursor.fetchall() finished_at=record.finished_at,
params=record.params or {},
loaded_tasks = [] result=record.result,
for row in rows: logs=logs
if has_plugin_id: )
task_id, plugin_id, status, created_at, input_request_json, context_json = row loaded_tasks.append(task)
else: except Exception as e:
task_id, status, created_at, input_request_json, context_json = row logger.error(f"Failed to reconstruct task {record.id}: {e}")
plugin_id = "superset-migration" # Default fallback
return loaded_tasks
finally:
session.close()
# [/DEF:load_tasks:Function]
try: # [DEF:delete_tasks:Function]
task = Task(
id=task_id,
plugin_id=plugin_id,
status=TaskStatus(status),
started_at=datetime.fromisoformat(created_at),
input_required=True,
input_request=json.loads(input_request_json) if input_request_json else None,
params=json.loads(context_json) if context_json else {}
)
loaded_tasks.append(task)
except Exception as e:
logger.error(f"Failed to load task {task_id}: {e}")
conn.close()
return loaded_tasks
# [/DEF:TaskPersistenceService.load_tasks:Function]
# [DEF:TaskPersistenceService.delete_tasks:Function]
# @PURPOSE: Deletes specific tasks from the database. # @PURPOSE: Deletes specific tasks from the database.
# @PRE: task_ids is a list of strings.
# @POST: Specified task records deleted from database.
# @PARAM: task_ids (List[str]) - List of task IDs to delete. # @PARAM: task_ids (List[str]) - List of task IDs to delete.
def delete_tasks(self, task_ids: List[str]) -> None: def delete_tasks(self, task_ids: List[str]) -> None:
if not task_ids: if not task_ids:
return return
with belief_scope("TaskPersistenceService.delete_tasks"): with belief_scope("TaskPersistenceService.delete_tasks"):
conn = sqlite3.connect(str(self.db_path)) session: Session = TasksSessionLocal()
cursor = conn.cursor() try:
placeholders = ', '.join('?' for _ in task_ids) session.query(TaskRecord).filter(TaskRecord.id.in_(task_ids)).delete(synchronize_session=False)
cursor.execute(f"DELETE FROM persistent_tasks WHERE id IN ({placeholders})", task_ids) session.commit()
conn.commit() except Exception as e:
conn.close() session.rollback()
# [/DEF:TaskPersistenceService.delete_tasks:Function] logger.error(f"Failed to delete tasks: {e}")
finally:
session.close()
# [/DEF:delete_tasks:Function]
# [/DEF:TaskPersistenceService:Class] # [/DEF:TaskPersistenceService:Class]
# [/DEF:TaskPersistenceModule:Module] # [/DEF:TaskPersistenceModule:Module]

View File

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

View File

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

View File

@@ -48,6 +48,6 @@ def suggest_mappings(source_databases: List[Dict], target_databases: List[Dict],
}) })
return suggestions return suggestions
# [/DEF:suggest_mappings] # [/DEF:suggest_mappings:Function]
# [/DEF:backend.src.core.utils.matching] # [/DEF:backend.src.core.utils.matching:Module]

View File

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

View File

@@ -8,6 +8,9 @@ from pathlib import Path
from .core.plugin_loader import PluginLoader from .core.plugin_loader import PluginLoader
from .core.task_manager import TaskManager from .core.task_manager import TaskManager
from .core.config_manager import ConfigManager from .core.config_manager import ConfigManager
from .core.scheduler import SchedulerService
from .core.database import init_db
from .core.logger import logger, belief_scope
# Initialize singletons # Initialize singletons
# Use absolute path relative to this file to ensure plugins are found regardless of CWD # Use absolute path relative to this file to ensure plugins are found regardless of CWD
@@ -15,24 +18,63 @@ project_root = Path(__file__).parent.parent.parent
config_path = project_root / "config.json" config_path = project_root / "config.json"
config_manager = ConfigManager(config_path=str(config_path)) config_manager = ConfigManager(config_path=str(config_path))
# Initialize database before any other services that might use it
init_db()
# [DEF:get_config_manager:Function]
# @PURPOSE: Dependency injector for the ConfigManager.
# @PRE: Global config_manager must be initialized.
# @POST: Returns shared ConfigManager instance.
# @RETURN: ConfigManager - The shared config manager instance.
def get_config_manager() -> ConfigManager: def get_config_manager() -> ConfigManager:
"""Dependency injector for the ConfigManager.""" """Dependency injector for the ConfigManager."""
return config_manager with belief_scope("get_config_manager"):
return config_manager
# [/DEF:get_config_manager:Function]
plugin_dir = Path(__file__).parent / "plugins" plugin_dir = Path(__file__).parent / "plugins"
plugin_loader = PluginLoader(plugin_dir=str(plugin_dir)) plugin_loader = PluginLoader(plugin_dir=str(plugin_dir))
from .core.logger import logger
logger.info(f"PluginLoader initialized with directory: {plugin_dir}") logger.info(f"PluginLoader initialized with directory: {plugin_dir}")
logger.info(f"Available plugins: {[config.name for config in plugin_loader.get_all_plugin_configs()]}") logger.info(f"Available plugins: {[config.name for config in plugin_loader.get_all_plugin_configs()]}")
task_manager = TaskManager(plugin_loader) task_manager = TaskManager(plugin_loader)
logger.info("TaskManager initialized") logger.info("TaskManager initialized")
scheduler_service = SchedulerService(task_manager, config_manager)
logger.info("SchedulerService initialized")
# [DEF:get_plugin_loader:Function]
# @PURPOSE: Dependency injector for the PluginLoader.
# @PRE: Global plugin_loader must be initialized.
# @POST: Returns shared PluginLoader instance.
# @RETURN: PluginLoader - The shared plugin loader instance.
def get_plugin_loader() -> PluginLoader: def get_plugin_loader() -> PluginLoader:
"""Dependency injector for the PluginLoader.""" """Dependency injector for the PluginLoader."""
return plugin_loader with belief_scope("get_plugin_loader"):
return plugin_loader
# [/DEF:get_plugin_loader:Function]
# [DEF:get_task_manager:Function]
# @PURPOSE: Dependency injector for the TaskManager.
# @PRE: Global task_manager must be initialized.
# @POST: Returns shared TaskManager instance.
# @RETURN: TaskManager - The shared task manager instance.
def get_task_manager() -> TaskManager: def get_task_manager() -> TaskManager:
"""Dependency injector for the TaskManager.""" """Dependency injector for the TaskManager."""
return task_manager with belief_scope("get_task_manager"):
# [/DEF] return task_manager
# [/DEF:get_task_manager:Function]
# [DEF:get_scheduler_service:Function]
# @PURPOSE: Dependency injector for the SchedulerService.
# @PRE: Global scheduler_service must be initialized.
# @POST: Returns shared SchedulerService instance.
# @RETURN: SchedulerService - The shared scheduler service instance.
def get_scheduler_service() -> SchedulerService:
"""Dependency injector for the SchedulerService."""
with belief_scope("get_scheduler_service"):
return scheduler_service
# [/DEF:get_scheduler_service:Function]
# [/DEF:Dependencies:Module]

View File

@@ -0,0 +1,34 @@
# [DEF:backend.src.models.connection:Module]
#
# @SEMANTICS: database, connection, configuration, sqlalchemy, sqlite
# @PURPOSE: Defines the database schema for external database connection configurations.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> sqlalchemy
#
# @INVARIANT: All primary keys are UUID strings.
# [SECTION: IMPORTS]
from sqlalchemy import Column, String, Integer, DateTime
from sqlalchemy.sql import func
from .mapping import Base
import uuid
# [/SECTION]
# [DEF:ConnectionConfig:Class]
# @PURPOSE: Stores credentials for external databases used for column mapping.
class ConnectionConfig(Base):
__tablename__ = "connection_configs"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
type = Column(String, nullable=False) # e.g., "postgres"
host = Column(String, nullable=True)
port = Column(Integer, nullable=True)
database = Column(String, nullable=True)
username = Column(String, nullable=True)
password = Column(String, nullable=True) # Encrypted/Obfuscated password
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# [/DEF:ConnectionConfig:Class]
# [/DEF:backend.src.models.connection:Module]

View File

@@ -14,7 +14,7 @@ class DashboardMetadata(BaseModel):
title: str title: str
last_modified: str last_modified: str
status: str status: str
# [/DEF:DashboardMetadata] # [/DEF:DashboardMetadata:Class]
# [DEF:DashboardSelection:Class] # [DEF:DashboardSelection:Class]
# @PURPOSE: Represents the user's selection of dashboards to migrate. # @PURPOSE: Represents the user's selection of dashboards to migrate.
@@ -23,6 +23,6 @@ class DashboardSelection(BaseModel):
source_env_id: str source_env_id: str
target_env_id: str target_env_id: str
replace_db_config: bool = False replace_db_config: bool = False
# [/DEF:DashboardSelection] # [/DEF:DashboardSelection:Class]
# [/DEF:backend.src.models.dashboard] # [/DEF:backend.src.models.dashboard:Module]

View File

@@ -26,7 +26,7 @@ class MigrationStatus(enum.Enum):
COMPLETED = "COMPLETED" COMPLETED = "COMPLETED"
FAILED = "FAILED" FAILED = "FAILED"
AWAITING_MAPPING = "AWAITING_MAPPING" AWAITING_MAPPING = "AWAITING_MAPPING"
# [/DEF:MigrationStatus] # [/DEF:MigrationStatus:Class]
# [DEF:Environment:Class] # [DEF:Environment:Class]
# @PURPOSE: Represents a Superset instance environment. # @PURPOSE: Represents a Superset instance environment.
@@ -37,7 +37,7 @@ class Environment(Base):
name = Column(String, nullable=False) name = Column(String, nullable=False)
url = Column(String, nullable=False) url = Column(String, nullable=False)
credentials_id = Column(String, nullable=False) credentials_id = Column(String, nullable=False)
# [/DEF:Environment] # [/DEF:Environment:Class]
# [DEF:DatabaseMapping:Class] # [DEF:DatabaseMapping:Class]
# @PURPOSE: Represents a mapping between source and target databases. # @PURPOSE: Represents a mapping between source and target databases.
@@ -52,7 +52,7 @@ class DatabaseMapping(Base):
source_db_name = Column(String, nullable=False) source_db_name = Column(String, nullable=False)
target_db_name = Column(String, nullable=False) target_db_name = Column(String, nullable=False)
engine = Column(String, nullable=True) engine = Column(String, nullable=True)
# [/DEF:DatabaseMapping] # [/DEF:DatabaseMapping:Class]
# [DEF:MigrationJob:Class] # [DEF:MigrationJob:Class]
# @PURPOSE: Represents a single migration execution job. # @PURPOSE: Represents a single migration execution job.
@@ -65,6 +65,6 @@ class MigrationJob(Base):
status = Column(SQLEnum(MigrationStatus), default=MigrationStatus.PENDING) status = Column(SQLEnum(MigrationStatus), default=MigrationStatus.PENDING)
replace_db = Column(Boolean, default=False) replace_db = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# [/DEF:MigrationJob] # [/DEF:MigrationJob:Class]
# [/DEF:backend.src.models.mapping] # [/DEF:backend.src.models.mapping:Module]

View File

@@ -0,0 +1,35 @@
# [DEF:backend.src.models.task:Module]
#
# @SEMANTICS: database, task, record, sqlalchemy, sqlite
# @PURPOSE: Defines the database schema for task execution records.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> sqlalchemy
#
# @INVARIANT: All primary keys are UUID strings.
# [SECTION: IMPORTS]
from sqlalchemy import Column, String, DateTime, JSON, ForeignKey
from sqlalchemy.sql import func
from .mapping import Base
import uuid
# [/SECTION]
# [DEF:TaskRecord:Class]
# @PURPOSE: Represents a persistent record of a task execution.
class TaskRecord(Base):
__tablename__ = "task_records"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
type = Column(String, nullable=False) # e.g., "backup", "migration"
status = Column(String, nullable=False) # Enum: "PENDING", "RUNNING", "SUCCESS", "FAILED"
environment_id = Column(String, ForeignKey("environments.id"), nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
finished_at = Column(DateTime(timezone=True), nullable=True)
logs = Column(JSON, nullable=True) # Store structured logs as JSON
error = Column(String, nullable=True)
result = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
params = Column(JSON, nullable=True)
# [/DEF:TaskRecord:Class]
# [/DEF:backend.src.models.task:Module]

View File

@@ -11,10 +11,10 @@ from pathlib import Path
from requests.exceptions import RequestException from requests.exceptions import RequestException
from ..core.plugin_base import PluginBase from ..core.plugin_base import PluginBase
from superset_tool.client import SupersetClient from ..core.logger import belief_scope
from superset_tool.exceptions import SupersetAPIError from ..core.superset_client import SupersetClient
from superset_tool.utils.logger import SupersetLogger from ..core.utils.network import SupersetAPIError
from superset_tool.utils.fileio import ( from ..core.utils.fileio import (
save_and_unpack_dashboard, save_and_unpack_dashboard,
archive_exports, archive_exports,
sanitize_filename, sanitize_filename,
@@ -22,33 +22,68 @@ 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]
# @PURPOSE: Implementation of the backup plugin logic.
class BackupPlugin(PluginBase): class BackupPlugin(PluginBase):
""" """
A plugin to back up Superset dashboards. A plugin to back up Superset dashboards.
""" """
@property @property
# [DEF:id:Function]
# @PURPOSE: Returns the unique identifier for the backup plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string ID.
# @RETURN: str - "superset-backup"
def id(self) -> str: def id(self) -> str:
return "superset-backup" with belief_scope("id"):
return "superset-backup"
# [/DEF:id:Function]
@property @property
# [DEF:name:Function]
# @PURPOSE: Returns the human-readable name of the backup plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string name.
# @RETURN: str - Plugin name.
def name(self) -> str: def name(self) -> str:
return "Superset Dashboard Backup" with belief_scope("name"):
return "Superset Dashboard Backup"
# [/DEF:name:Function]
@property @property
# [DEF:description:Function]
# @PURPOSE: Returns a description of the backup plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string description.
# @RETURN: str - Plugin description.
def description(self) -> str: def description(self) -> str:
return "Backs up all dashboards from a Superset instance." with belief_scope("description"):
return "Backs up all dashboards from a Superset instance."
# [/DEF:description:Function]
@property @property
# [DEF:version:Function]
# @PURPOSE: Returns the version of the backup plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string version.
# @RETURN: str - "1.0.0"
def version(self) -> str: def version(self) -> str:
return "1.0.0" with belief_scope("version"):
return "1.0.0"
# [/DEF:version:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for backup plugin parameters.
# @PRE: Plugin instance exists.
# @POST: Returns dictionary schema.
# @RETURN: Dict[str, Any] - JSON schema.
def get_schema(self) -> Dict[str, Any]: def get_schema(self) -> Dict[str, Any]:
config_manager = get_config_manager() with belief_scope("get_schema"):
envs = [e.name for e in config_manager.get_environments()] config_manager = get_config_manager()
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.backup_path
return { return {
@@ -69,65 +104,86 @@ class BackupPlugin(PluginBase):
}, },
"required": ["env", "backup_path"], "required": ["env", "backup_path"],
} }
# [/DEF:get_schema:Function]
# [DEF:execute:Function]
# @PURPOSE: Executes the dashboard backup logic.
# @PARAM: params (Dict[str, Any]) - Backup parameters (env, backup_path).
# @PRE: Target environment must be configured. params must be a dictionary.
# @POST: All dashboards are exported and archived.
async def execute(self, params: Dict[str, Any]): async def execute(self, params: Dict[str, Any]):
env = params["env"] with belief_scope("execute"):
backup_path = Path(params["backup_path"])
logger = SupersetLogger(log_dir=backup_path / "Logs", console=True)
logger.info(f"[BackupPlugin][Entry] Starting backup for {env}.")
try:
config_manager = get_config_manager() config_manager = get_config_manager()
if not config_manager.has_environments(): env_id = params.get("environment_id")
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
# Resolve environment name if environment_id is provided
if env_id:
env_config = next((e for e in config_manager.get_environments() if e.id == env_id), None)
if env_config:
params["env"] = env_config.name
env = params.get("env")
if not env:
raise KeyError("env")
backup_path_str = params.get("backup_path") or config_manager.get_config().settings.backup_path
backup_path = Path(backup_path_str)
from ..core.logger import logger as app_logger
app_logger.info(f"[BackupPlugin][Entry] Starting backup for {env}.")
try:
config_manager = get_config_manager()
if not config_manager.has_environments():
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
env_config = config_manager.get_environment(env)
if not env_config:
raise ValueError(f"Environment '{env}' not found in configuration.")
clients = setup_clients(logger, custom_envs=config_manager.get_environments()) client = SupersetClient(env_config)
client = clients.get(env)
dashboard_count, dashboard_meta = client.get_dashboards()
if not client: app_logger.info(f"[BackupPlugin][Progress] Found {dashboard_count} dashboards to export in {env}.")
raise ValueError(f"Environment '{env}' not found in configuration.")
dashboard_count, dashboard_meta = client.get_dashboards()
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:
dashboard_id = db.get('id') dashboard_id = db.get('id')
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard') dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
if not dashboard_id: if not dashboard_id:
continue continue
try: try:
dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}") dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}")
dashboard_dir = backup_path / env.upper() / dashboard_base_dir_name dashboard_dir = backup_path / env.upper() / dashboard_base_dir_name
dashboard_dir.mkdir(parents=True, exist_ok=True) dashboard_dir.mkdir(parents=True, exist_ok=True)
zip_content, filename = client.export_dashboard(dashboard_id) zip_content, filename = client.export_dashboard(dashboard_id)
save_and_unpack_dashboard( save_and_unpack_dashboard(
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:BackupPlugin] # [/DEF:execute:Function]
# [/DEF:BackupPlugin:Class]
# [/DEF:BackupPlugin:Module]

View File

@@ -0,0 +1,187 @@
# [DEF:DebugPluginModule:Module]
# @SEMANTICS: plugin, debug, api, database, superset
# @PURPOSE: Implements a plugin for system diagnostics and debugging Superset API responses.
# @LAYER: Plugins
# @RELATION: Inherits from PluginBase. Uses SupersetClient from core.
# @CONSTRAINT: Must use belief_scope for logging.
# [SECTION: IMPORTS]
from typing import Dict, Any, Optional
from ..core.plugin_base import PluginBase
from ..core.superset_client import SupersetClient
from ..core.logger import logger, belief_scope
# [/SECTION]
# [DEF:DebugPlugin:Class]
# @PURPOSE: Plugin for system diagnostics and debugging.
class DebugPlugin(PluginBase):
"""
Plugin for system diagnostics and debugging.
"""
@property
# [DEF:id:Function]
# @PURPOSE: Returns the unique identifier for the debug plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string ID.
# @RETURN: str - "system-debug"
def id(self) -> str:
with belief_scope("id"):
return "system-debug"
# [/DEF:id:Function]
@property
# [DEF:name:Function]
# @PURPOSE: Returns the human-readable name of the debug plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string name.
# @RETURN: str - Plugin name.
def name(self) -> str:
with belief_scope("name"):
return "System Debug"
# [/DEF:name:Function]
@property
# [DEF:description:Function]
# @PURPOSE: Returns a description of the debug plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string description.
# @RETURN: str - Plugin description.
def description(self) -> str:
with belief_scope("description"):
return "Run system diagnostics and debug Superset API responses."
# [/DEF:description:Function]
@property
# [DEF:version:Function]
# @PURPOSE: Returns the version of the debug plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string version.
# @RETURN: str - "1.0.0"
def version(self) -> str:
with belief_scope("version"):
return "1.0.0"
# [/DEF:version:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the debug plugin parameters.
# @PRE: Plugin instance exists.
# @POST: Returns dictionary schema.
# @RETURN: Dict[str, Any] - JSON schema.
def get_schema(self) -> Dict[str, Any]:
with belief_scope("get_schema"):
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"title": "Action",
"enum": ["test-db-api", "get-dataset-structure"],
"default": "test-db-api"
},
"env": {
"type": "string",
"title": "Environment",
"description": "The Superset environment (for dataset structure)."
},
"dataset_id": {
"type": "integer",
"title": "Dataset ID",
"description": "The ID of the dataset (for dataset structure)."
},
"source_env": {
"type": "string",
"title": "Source Environment",
"description": "Source env for DB API test."
},
"target_env": {
"type": "string",
"title": "Target Environment",
"description": "Target env for DB API test."
}
},
"required": ["action"]
}
# [/DEF:get_schema:Function]
# [DEF:execute:Function]
# @PURPOSE: Executes the debug logic.
# @PARAM: params (Dict[str, Any]) - Debug parameters.
# @PRE: action must be provided in params.
# @POST: Debug action is executed and results returned.
# @RETURN: Dict[str, Any] - Execution results.
async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
with belief_scope("execute"):
action = params.get("action")
if action == "test-db-api":
return await self._test_db_api(params)
elif action == "get-dataset-structure":
return await self._get_dataset_structure(params)
else:
raise ValueError(f"Unknown action: {action}")
# [/DEF:execute:Function]
# [DEF:_test_db_api:Function]
# @PURPOSE: Tests database API connectivity for source and target environments.
# @PRE: source_env and target_env params exist in params.
# @POST: Returns DB counts for both envs.
# @PARAM: params (Dict) - Plugin parameters.
# @RETURN: Dict - Comparison results.
async def _test_db_api(self, params: Dict[str, Any]) -> Dict[str, Any]:
with belief_scope("_test_db_api"):
source_env_name = params.get("source_env")
target_env_name = params.get("target_env")
if not source_env_name or not target_env_name:
raise ValueError("source_env and target_env are required for test-db-api")
from ..dependencies import get_config_manager
config_manager = get_config_manager()
results = {}
for name in [source_env_name, target_env_name]:
env_config = config_manager.get_environment(name)
if not env_config:
raise ValueError(f"Environment '{name}' not found.")
client = SupersetClient(env_config)
client.authenticate()
count, dbs = client.get_databases()
results[name] = {
"count": count,
"databases": dbs
}
return results
# [/DEF:_test_db_api:Function]
# [DEF:_get_dataset_structure:Function]
# @PURPOSE: Retrieves the structure of a dataset.
# @PRE: env and dataset_id params exist in params.
# @POST: Returns dataset JSON structure.
# @PARAM: params (Dict) - Plugin parameters.
# @RETURN: Dict - Dataset structure.
async def _get_dataset_structure(self, params: Dict[str, Any]) -> Dict[str, Any]:
with belief_scope("_get_dataset_structure"):
env_name = params.get("env")
dataset_id = params.get("dataset_id")
if not env_name or dataset_id is None:
raise ValueError("env and dataset_id are required for get-dataset-structure")
from ..dependencies import get_config_manager
config_manager = get_config_manager()
env_config = config_manager.get_environment(env_name)
if not env_config:
raise ValueError(f"Environment '{env_name}' not found.")
client = SupersetClient(env_config)
client.authenticate()
dataset_response = client.get_dataset(dataset_id)
return dataset_response.get('result') or {}
# [/DEF:_get_dataset_structure:Function]
# [/DEF:DebugPlugin:Class]
# [/DEF:DebugPluginModule:Module]

View File

@@ -0,0 +1,195 @@
# [DEF:MapperPluginModule:Module]
# @SEMANTICS: plugin, mapper, datasets, postgresql, excel
# @PURPOSE: Implements a plugin for mapping dataset columns using external database connections or Excel files.
# @LAYER: Plugins
# @RELATION: Inherits from PluginBase. Uses DatasetMapper from superset_tool.
# @CONSTRAINT: Must use belief_scope for logging.
# [SECTION: IMPORTS]
from typing import Dict, Any, Optional
from ..core.plugin_base import PluginBase
from ..core.superset_client import SupersetClient
from ..core.logger import logger, belief_scope
from ..core.database import SessionLocal
from ..models.connection import ConnectionConfig
from ..core.utils.dataset_mapper import DatasetMapper
# [/SECTION]
# [DEF:MapperPlugin:Class]
# @PURPOSE: Plugin for mapping dataset columns verbose names.
class MapperPlugin(PluginBase):
"""
Plugin for mapping dataset columns verbose names.
"""
@property
# [DEF:id:Function]
# @PURPOSE: Returns the unique identifier for the mapper plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string ID.
# @RETURN: str - "dataset-mapper"
def id(self) -> str:
with belief_scope("id"):
return "dataset-mapper"
# [/DEF:id:Function]
@property
# [DEF:name:Function]
# @PURPOSE: Returns the human-readable name of the mapper plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string name.
# @RETURN: str - Plugin name.
def name(self) -> str:
with belief_scope("name"):
return "Dataset Mapper"
# [/DEF:name:Function]
@property
# [DEF:description:Function]
# @PURPOSE: Returns a description of the mapper plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string description.
# @RETURN: str - Plugin description.
def description(self) -> str:
with belief_scope("description"):
return "Map dataset column verbose names using PostgreSQL comments or Excel files."
# [/DEF:description:Function]
@property
# [DEF:version:Function]
# @PURPOSE: Returns the version of the mapper plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string version.
# @RETURN: str - "1.0.0"
def version(self) -> str:
with belief_scope("version"):
return "1.0.0"
# [/DEF:version:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the mapper plugin parameters.
# @PRE: Plugin instance exists.
# @POST: Returns dictionary schema.
# @RETURN: Dict[str, Any] - JSON schema.
def get_schema(self) -> Dict[str, Any]:
with belief_scope("get_schema"):
return {
"type": "object",
"properties": {
"env": {
"type": "string",
"title": "Environment",
"description": "The Superset environment (e.g., 'dev')."
},
"dataset_id": {
"type": "integer",
"title": "Dataset ID",
"description": "The ID of the dataset to update."
},
"source": {
"type": "string",
"title": "Mapping Source",
"enum": ["postgres", "excel"],
"default": "postgres"
},
"connection_id": {
"type": "string",
"title": "Saved Connection",
"description": "The ID of a saved database connection (for postgres source)."
},
"table_name": {
"type": "string",
"title": "Table Name",
"description": "Target table name in PostgreSQL."
},
"table_schema": {
"type": "string",
"title": "Table Schema",
"description": "Target table schema in PostgreSQL.",
"default": "public"
},
"excel_path": {
"type": "string",
"title": "Excel Path",
"description": "Path to the Excel file (for excel source)."
}
},
"required": ["env", "dataset_id", "source"]
}
# [/DEF:get_schema:Function]
# [DEF:execute:Function]
# @PURPOSE: Executes the dataset mapping logic.
# @PARAM: params (Dict[str, Any]) - Mapping parameters.
# @PRE: Params contain valid 'env', 'dataset_id', and 'source'. params must be a dictionary.
# @POST: Updates the dataset in Superset.
# @RETURN: Dict[str, Any] - Execution status.
async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
with belief_scope("execute"):
env_name = params.get("env")
dataset_id = params.get("dataset_id")
source = params.get("source")
if not env_name or dataset_id is None or not source:
logger.error("[MapperPlugin.execute][State] Missing required parameters.")
raise ValueError("Missing required parameters: env, dataset_id, source")
# Get config and initialize client
from ..dependencies import get_config_manager
config_manager = get_config_manager()
env_config = config_manager.get_environment(env_name)
if not env_config:
logger.error(f"[MapperPlugin.execute][State] Environment '{env_name}' not found.")
raise ValueError(f"Environment '{env_name}' not found in configuration.")
client = SupersetClient(env_config)
client.authenticate()
postgres_config = None
if source == "postgres":
connection_id = params.get("connection_id")
if not connection_id:
logger.error("[MapperPlugin.execute][State] connection_id is required for postgres source.")
raise ValueError("connection_id is required for postgres source.")
# Load connection from DB
db = SessionLocal()
try:
conn_config = db.query(ConnectionConfig).filter(ConnectionConfig.id == connection_id).first()
if not conn_config:
logger.error(f"[MapperPlugin.execute][State] Connection {connection_id} not found.")
raise ValueError(f"Connection {connection_id} not found.")
postgres_config = {
'dbname': conn_config.database,
'user': conn_config.username,
'password': conn_config.password,
'host': conn_config.host,
'port': str(conn_config.port) if conn_config.port else '5432'
}
finally:
db.close()
logger.info(f"[MapperPlugin.execute][Action] Starting mapping for dataset {dataset_id} in {env_name}")
mapper = DatasetMapper()
try:
mapper.run_mapping(
superset_client=client,
dataset_id=dataset_id,
source=source,
postgres_config=postgres_config,
excel_path=params.get("excel_path"),
table_name=params.get("table_name"),
table_schema=params.get("table_schema") or "public"
)
logger.info(f"[MapperPlugin.execute][Success] Mapping completed for dataset {dataset_id}")
return {"status": "success", "dataset_id": dataset_id}
except Exception as e:
logger.error(f"[MapperPlugin.execute][Failure] Mapping failed: {e}")
raise
# [/DEF:execute:Function]
# [/DEF:MapperPlugin:Class]
# [/DEF:MapperPluginModule:Module]

View File

@@ -12,38 +12,73 @@ import zipfile
import re import re
from ..core.plugin_base import PluginBase from ..core.plugin_base import PluginBase
from superset_tool.client import SupersetClient from ..core.logger import belief_scope
from superset_tool.utils.init_clients import setup_clients from ..core.superset_client import SupersetClient
from superset_tool.utils.fileio import create_temp_file, update_yamls, create_dashboard_export from ..core.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
# [DEF:MigrationPlugin:Class]
# @PURPOSE: Implementation of the migration plugin logic.
class MigrationPlugin(PluginBase): class MigrationPlugin(PluginBase):
""" """
A plugin to migrate Superset dashboards between environments. A plugin to migrate Superset dashboards between environments.
""" """
@property @property
# [DEF:id:Function]
# @PURPOSE: Returns the unique identifier for the migration plugin.
# @PRE: None.
# @POST: Returns "superset-migration".
# @RETURN: str - "superset-migration"
def id(self) -> str: def id(self) -> str:
return "superset-migration" with belief_scope("id"):
return "superset-migration"
# [/DEF:id:Function]
@property @property
# [DEF:name:Function]
# @PURPOSE: Returns the human-readable name of the migration plugin.
# @PRE: None.
# @POST: Returns the plugin name.
# @RETURN: str - Plugin name.
def name(self) -> str: def name(self) -> str:
return "Superset Dashboard Migration" with belief_scope("name"):
return "Superset Dashboard Migration"
# [/DEF:name:Function]
@property @property
# [DEF:description:Function]
# @PURPOSE: Returns a description of the migration plugin.
# @PRE: None.
# @POST: Returns the plugin description.
# @RETURN: str - Plugin description.
def description(self) -> str: def description(self) -> str:
return "Migrates dashboards between Superset environments." with belief_scope("description"):
return "Migrates dashboards between Superset environments."
# [/DEF:description:Function]
@property @property
# [DEF:version:Function]
# @PURPOSE: Returns the version of the migration plugin.
# @PRE: None.
# @POST: Returns "1.0.0".
# @RETURN: str - "1.0.0"
def version(self) -> str: def version(self) -> str:
return "1.0.0" with belief_scope("version"):
return "1.0.0"
# [/DEF:version:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for migration plugin parameters.
# @PRE: Config manager is available.
# @POST: Returns a valid JSON schema dictionary.
# @RETURN: Dict[str, Any] - JSON schema.
def get_schema(self) -> Dict[str, Any]: def get_schema(self) -> Dict[str, Any]:
config_manager = get_config_manager() with belief_scope("get_schema"):
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()]
return { return {
@@ -85,11 +120,18 @@ class MigrationPlugin(PluginBase):
}, },
"required": ["from_env", "to_env", "dashboard_regex"], "required": ["from_env", "to_env", "dashboard_regex"],
} }
# [/DEF:get_schema:Function]
# [DEF:execute:Function]
# @PURPOSE: Executes the dashboard migration logic.
# @PARAM: params (Dict[str, Any]) - Migration parameters.
# @PRE: Source and target environments must be configured.
# @POST: Selected dashboards are migrated.
async def execute(self, params: Dict[str, Any]): async def execute(self, params: Dict[str, Any]):
source_env_id = params.get("source_env_id") with belief_scope("MigrationPlugin.execute"):
target_env_id = params.get("target_env_id") source_env_id = params.get("source_env_id")
selected_ids = params.get("selected_ids") target_env_id = params.get("target_env_id")
selected_ids = params.get("selected_ids")
# Legacy support or alternative params # Legacy support or alternative params
from_env_name = params.get("from_env") from_env_name = params.get("from_env")
@@ -106,30 +148,78 @@ 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]
# @PURPOSE: Initializes the proxy logger.
# @PRE: None.
# @POST: Instance is initialized.
def __init__(self): def __init__(self):
# Initialize parent with dummy values since we override methods with belief_scope("__init__"):
super().__init__(console=False) # Initialize parent with dummy values since we override methods
pass
# [/DEF:__init__:Function]
# [DEF:debug:Function]
# @PURPOSE: Logs a debug message to the task manager.
# @PRE: msg is a string.
# @POST: Log is added to task manager if task_id exists.
def debug(self, msg, *args, extra=None, **kwargs): def debug(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "DEBUG", msg, extra or {}) with belief_scope("debug"):
if task_id: tm._add_log(task_id, "DEBUG", msg, extra or {})
# [/DEF:debug:Function]
# [DEF:info:Function]
# @PURPOSE: Logs an info message to the task manager.
# @PRE: msg is a string.
# @POST: Log is added to task manager if task_id exists.
def info(self, msg, *args, extra=None, **kwargs): def info(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "INFO", msg, extra or {}) with belief_scope("info"):
if task_id: tm._add_log(task_id, "INFO", msg, extra or {})
# [/DEF:info:Function]
# [DEF:warning:Function]
# @PURPOSE: Logs a warning message to the task manager.
# @PRE: msg is a string.
# @POST: Log is added to task manager if task_id exists.
def warning(self, msg, *args, extra=None, **kwargs): def warning(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "WARNING", msg, extra or {}) with belief_scope("warning"):
if task_id: tm._add_log(task_id, "WARNING", msg, extra or {})
# [/DEF:warning:Function]
# [DEF:error:Function]
# @PURPOSE: Logs an error message to the task manager.
# @PRE: msg is a string.
# @POST: Log is added to task manager if task_id exists.
def error(self, msg, *args, extra=None, **kwargs): def error(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "ERROR", msg, extra or {}) with belief_scope("error"):
if task_id: tm._add_log(task_id, "ERROR", msg, extra or {})
# [/DEF:error:Function]
# [DEF:critical:Function]
# @PURPOSE: Logs a critical message to the task manager.
# @PRE: msg is a string.
# @POST: Log is added to task manager if task_id exists.
def critical(self, msg, *args, extra=None, **kwargs): def critical(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "ERROR", msg, extra or {}) with belief_scope("critical"):
if task_id: tm._add_log(task_id, "ERROR", msg, extra or {})
# [/DEF:critical:Function]
# [DEF:exception:Function]
# @PURPOSE: Logs an exception message to the task manager.
# @PRE: msg is a string.
# @POST: Log is added to task manager if task_id exists.
def exception(self, msg, *args, **kwargs): def exception(self, msg, *args, **kwargs):
if task_id: tm._add_log(task_id, "ERROR", msg, {"exception": True}) with belief_scope("exception"):
if task_id: tm._add_log(task_id, "ERROR", msg, {"exception": True})
# [/DEF:exception:Function]
logger = TaskLoggerProxy() logger = TaskLoggerProxy()
logger.info(f"[MigrationPlugin][Entry] Starting migration task.") logger.info(f"[MigrationPlugin][Entry] Starting migration task.")
logger.info(f"[MigrationPlugin][Action] Params: {params}") logger.info(f"[MigrationPlugin][Action] Params: {params}")
try: try:
config_manager = get_config_manager() with belief_scope("execute"):
config_manager = get_config_manager()
environments = config_manager.get_environments() environments = config_manager.get_environments()
# Resolve environments # Resolve environments
@@ -154,9 +244,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}")
@@ -288,9 +377,11 @@ class MigrationPlugin(PluginBase):
logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True) logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
logger.info("[MigrationPlugin][Exit] Migration finished.") logger.info("[MigrationPlugin][Exit] Migration finished.")
except Exception as e: except Exception as e:
logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True) logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
raise e raise e
# [/DEF:MigrationPlugin] # [/DEF:MigrationPlugin.execute:Action]
# [/DEF:execute:Function]
# [/DEF:MigrationPlugin:Class]
# [/DEF:MigrationPlugin:Module]

View File

@@ -0,0 +1,202 @@
# [DEF:SearchPluginModule:Module]
# @SEMANTICS: plugin, search, datasets, regex, superset
# @PURPOSE: Implements a plugin for searching text patterns across all datasets in a specific Superset environment.
# @LAYER: Plugins
# @RELATION: Inherits from PluginBase. Uses SupersetClient from core.
# @CONSTRAINT: Must use belief_scope for logging.
# [SECTION: IMPORTS]
import re
from typing import Dict, Any, List, Optional
from ..core.plugin_base import PluginBase
from ..core.superset_client import SupersetClient
from ..core.logger import logger, belief_scope
# [/SECTION]
# [DEF:SearchPlugin:Class]
# @PURPOSE: Plugin for searching text patterns in Superset datasets.
class SearchPlugin(PluginBase):
"""
Plugin for searching text patterns in Superset datasets.
"""
@property
# [DEF:id:Function]
# @PURPOSE: Returns the unique identifier for the search plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string ID.
# @RETURN: str - "search-datasets"
def id(self) -> str:
with belief_scope("id"):
return "search-datasets"
# [/DEF:id:Function]
@property
# [DEF:name:Function]
# @PURPOSE: Returns the human-readable name of the search plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string name.
# @RETURN: str - Plugin name.
def name(self) -> str:
with belief_scope("name"):
return "Search Datasets"
# [/DEF:name:Function]
@property
# [DEF:description:Function]
# @PURPOSE: Returns a description of the search plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string description.
# @RETURN: str - Plugin description.
def description(self) -> str:
with belief_scope("description"):
return "Search for text patterns across all datasets in a specific environment."
# [/DEF:description:Function]
@property
# [DEF:version:Function]
# @PURPOSE: Returns the version of the search plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string version.
# @RETURN: str - "1.0.0"
def version(self) -> str:
with belief_scope("version"):
return "1.0.0"
# [/DEF:version:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the search plugin parameters.
# @PRE: Plugin instance exists.
# @POST: Returns dictionary schema.
# @RETURN: Dict[str, Any] - JSON schema.
def get_schema(self) -> Dict[str, Any]:
with belief_scope("get_schema"):
return {
"type": "object",
"properties": {
"env": {
"type": "string",
"title": "Environment",
"description": "The Superset environment to search in (e.g., 'dev', 'prod')."
},
"query": {
"type": "string",
"title": "Search Query (Regex)",
"description": "The regex pattern to search for."
}
},
"required": ["env", "query"]
}
# [/DEF:get_schema:Function]
# [DEF:execute:Function]
# @PURPOSE: Executes the dataset search logic.
# @PARAM: params (Dict[str, Any]) - Search parameters.
# @PRE: Params contain valid 'env' and 'query'.
# @POST: Returns a dictionary with count and results list.
# @RETURN: Dict[str, Any] - Search results.
async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
with belief_scope("SearchPlugin.execute", f"params={params}"):
env_name = params.get("env")
search_query = params.get("query")
if not env_name or not search_query:
logger.error("[SearchPlugin.execute][State] Missing required parameters.")
raise ValueError("Missing required parameters: env, query")
# Get config and initialize client
from ..dependencies import get_config_manager
config_manager = get_config_manager()
env_config = config_manager.get_environment(env_name)
if not env_config:
logger.error(f"[SearchPlugin.execute][State] Environment '{env_name}' not found.")
raise ValueError(f"Environment '{env_name}' not found in configuration.")
client = SupersetClient(env_config)
client.authenticate()
logger.info(f"[SearchPlugin.execute][Action] Searching for pattern: '{search_query}' in environment: {env_name}")
try:
# Ported logic from search_script.py
_, datasets = client.get_datasets(query={"columns": ["id", "table_name", "sql", "database", "columns"]})
if not datasets:
logger.warning("[SearchPlugin.execute][State] No datasets found.")
return {"count": 0, "results": []}
pattern = re.compile(search_query, re.IGNORECASE)
results = []
for dataset in datasets:
dataset_id = dataset.get('id')
dataset_name = dataset.get('table_name', 'Unknown')
if not dataset_id:
continue
for field, value in dataset.items():
value_str = str(value)
if pattern.search(value_str):
match_obj = pattern.search(value_str)
results.append({
"dataset_id": dataset_id,
"dataset_name": dataset_name,
"field": field,
"match_context": self._get_context(value_str, match_obj.group() if match_obj else ""),
"full_value": value_str
})
logger.info(f"[SearchPlugin.execute][Success] Found matches in {len(results)} locations.")
return {
"count": len(results),
"results": results
}
except re.error as e:
logger.error(f"[SearchPlugin.execute][Failure] Invalid regex pattern: {e}")
raise ValueError(f"Invalid regex pattern: {e}")
except Exception as e:
logger.error(f"[SearchPlugin.execute][Failure] Error during search: {e}")
raise
# [/DEF:execute:Function]
# [DEF:_get_context:Function]
# @PURPOSE: Extracts a small context around the match for display.
# @PARAM: text (str) - The full text to extract context from.
# @PARAM: match_text (str) - The matched text pattern.
# @PARAM: context_lines (int) - Number of lines of context to include.
# @PRE: text and match_text must be strings.
# @POST: Returns context string.
# @RETURN: str - Extracted context.
def _get_context(self, text: str, match_text: str, context_lines: int = 1) -> str:
"""
Extracts a small context around the match for display.
"""
with belief_scope("_get_context"):
if not match_text:
return text[:100] + "..." if len(text) > 100 else text
lines = text.splitlines()
match_line_index = -1
for i, line in enumerate(lines):
if match_text in line:
match_line_index = i
break
if match_line_index != -1:
start = max(0, match_line_index - context_lines)
end = min(len(lines), match_line_index + context_lines + 1)
context = []
for i in range(start, end):
line_content = lines[i]
if i == match_line_index:
context.append(f"==> {line_content}")
else:
context.append(f" {line_content}")
return "\n".join(context)
return text[:100] + "..." if len(text) > 100 else text
# [/DEF:_get_context:Function]
# [/DEF:SearchPlugin:Class]
# [/DEF:SearchPluginModule:Module]

View File

@@ -10,57 +10,62 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from typing import List, Dict from typing import List, Dict
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]
# @PURPOSE: Service for handling database mapping logic. # @PURPOSE: Service for handling database mapping logic.
class MappingService: class MappingService:
# [DEF:MappingService.__init__:Function] # [DEF:__init__:Function]
# @PURPOSE: Initializes the mapping service with a config manager.
# @PRE: config_manager is provided.
# @PARAM: config_manager (ConfigManager) - The configuration manager.
# @POST: Service is initialized.
def __init__(self, config_manager): def __init__(self, config_manager):
self.config_manager = config_manager with belief_scope("MappingService.__init__"):
self.config_manager = config_manager
# [/DEF:__init__:Function]
# [DEF:MappingService._get_client:Function] # [DEF:_get_client:Function]
# @PURPOSE: Helper to get an initialized SupersetClient for an environment. # @PURPOSE: Helper to get an initialized SupersetClient for an environment.
# @PARAM: env_id (str) - The ID of the environment.
# @PRE: environment must exist in config.
# @POST: Returns an initialized SupersetClient.
# @RETURN: SupersetClient - Initialized client.
def _get_client(self, env_id: str) -> SupersetClient: def _get_client(self, env_id: str) -> SupersetClient:
envs = self.config_manager.get_environments() with belief_scope("MappingService._get_client", f"env_id={env_id}"):
env = next((e for e in envs if e.id == env_id), None) envs = self.config_manager.get_environments()
if not env: env = next((e for e in envs if e.id == env_id), None)
raise ValueError(f"Environment {env_id} not found") if not env:
raise ValueError(f"Environment {env_id} not found")
superset_config = SupersetConfig(
env=env.name, return SupersetClient(env)
base_url=env.url, # [/DEF:_get_client:Function]
auth={
"provider": "db",
"username": env.username,
"password": env.password,
"refresh": "false"
}
)
return SupersetClient(superset_config)
# [DEF:MappingService.get_suggestions:Function] # [DEF:get_suggestions:Function]
# @PURPOSE: Fetches databases from both environments and returns fuzzy matching suggestions. # @PURPOSE: Fetches databases from both environments and returns fuzzy matching suggestions.
# @PARAM: source_env_id (str) - Source environment ID. # @PARAM: source_env_id (str) - Source environment ID.
# @PARAM: target_env_id (str) - Target environment ID. # @PARAM: target_env_id (str) - Target environment ID.
# @PRE: Both environments must be accessible.
# @POST: Returns fuzzy-matched database suggestions.
# @RETURN: List[Dict] - Suggested mappings. # @RETURN: List[Dict] - Suggested mappings.
async def get_suggestions(self, source_env_id: str, target_env_id: str) -> List[Dict]: async def get_suggestions(self, source_env_id: str, target_env_id: str) -> List[Dict]:
""" with belief_scope("MappingService.get_suggestions", f"source={source_env_id}, target={target_env_id}"):
Get suggested mappings between two environments. """
""" Get suggested mappings between two environments.
source_client = self._get_client(source_env_id) """
target_client = self._get_client(target_env_id) source_client = self._get_client(source_env_id)
target_client = self._get_client(target_env_id)
source_dbs = source_client.get_databases_summary()
target_dbs = target_client.get_databases_summary() source_dbs = source_client.get_databases_summary()
target_dbs = target_client.get_databases_summary()
return suggest_mappings(source_dbs, target_dbs)
# [/DEF:MappingService.get_suggestions] return suggest_mappings(source_dbs, target_dbs)
# [/DEF:get_suggestions:Function]
# [/DEF:MappingService] # [/DEF:MappingService:Class]
# [/DEF:backend.src.services.mapping_service] # [/DEF:backend.src.services.mapping_service:Module]

BIN
backend/tasks.db Normal file

Binary file not shown.

View File

@@ -1,7 +1,11 @@
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]
# @PURPOSE: Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs.
# @PRE: belief_scope is available. caplog fixture is used.
# @POST: Logs are verified to contain Entry, Action, and Exit tags.
def test_belief_scope_logs_entry_action_exit(caplog): def test_belief_scope_logs_entry_action_exit(caplog):
"""Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs.""" """Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs."""
caplog.set_level("INFO") caplog.set_level("INFO")
@@ -15,8 +19,13 @@ def test_belief_scope_logs_entry_action_exit(caplog):
assert any("[TestFunction][Entry]" in msg for msg in log_messages), "Entry log not found" assert any("[TestFunction][Entry]" in msg for msg in log_messages), "Entry log not found"
assert any("[TestFunction][Action] Doing something important" in msg for msg in log_messages), "Action log not found" assert any("[TestFunction][Action] Doing something important" in msg for msg in log_messages), "Action log not found"
assert any("[TestFunction][Exit]" in msg for msg in log_messages), "Exit log not found" assert any("[TestFunction][Exit]" in msg for msg in log_messages), "Exit log not found"
# [/DEF:test_belief_scope_logs_entry_action_exit:Function]
# [DEF:test_belief_scope_error_handling:Function]
# @PURPOSE: Test that belief_scope logs Coherence:Failed on exception.
# @PRE: belief_scope is available. caplog fixture is used.
# @POST: Logs are verified to contain Coherence:Failed tag.
def test_belief_scope_error_handling(caplog): def test_belief_scope_error_handling(caplog):
"""Test that belief_scope logs Coherence:Failed on exception.""" """Test that belief_scope logs Coherence:Failed on exception."""
caplog.set_level("INFO") caplog.set_level("INFO")
@@ -30,8 +39,13 @@ def test_belief_scope_error_handling(caplog):
assert any("[FailingFunction][Entry]" in msg for msg in log_messages), "Entry log not found" assert any("[FailingFunction][Entry]" in msg for msg in log_messages), "Entry log not found"
assert any("[FailingFunction][Coherence:Failed]" in msg for msg in log_messages), "Failed coherence log not found" assert any("[FailingFunction][Coherence:Failed]" in msg for msg in log_messages), "Failed coherence log not found"
# Exit should not be logged on failure # Exit should not be logged on failure
# [/DEF:test_belief_scope_error_handling:Function]
# [DEF:test_belief_scope_success_coherence:Function]
# @PURPOSE: Test that belief_scope logs Coherence:OK on success.
# @PRE: belief_scope is available. caplog fixture is used.
# @POST: Logs are verified to contain Coherence:OK tag.
def test_belief_scope_success_coherence(caplog): def test_belief_scope_success_coherence(caplog):
"""Test that belief_scope logs Coherence:OK on success.""" """Test that belief_scope logs Coherence:OK on success."""
caplog.set_level("INFO") caplog.set_level("INFO")
@@ -41,4 +55,5 @@ def test_belief_scope_success_coherence(caplog):
log_messages = [record.message for record in caplog.records] log_messages = [record.message for record in caplog.records]
assert any("[SuccessFunction][Coherence:OK]" in msg for msg in log_messages), "Success coherence log not found" assert any("[SuccessFunction][Coherence:OK]" in msg for msg in log_messages), "Success coherence log not found"
# [/DEF:test_belief_scope_success_coherence:Function]

View File

@@ -1,49 +1,23 @@
import pytest import pytest
from superset_tool.models import SupersetConfig from src.core.config_models import Environment
from src.core.logger import belief_scope
def test_superset_config_url_normalization(): # [DEF:test_environment_model:Function]
auth = { # @PURPOSE: Tests that Environment model correctly stores values.
"provider": "db", # @PRE: Environment class is available.
"username": "admin", # @POST: Values are verified.
"password": "password", def test_environment_model():
"refresh": "token" with belief_scope("test_environment_model"):
} env = Environment(
id="test-id",
# Test with /api/v1 already present name="test-env",
config = SupersetConfig( url="http://localhost:8088/api/v1",
env="dev", username="admin",
base_url="http://localhost:8088/api/v1", password="password"
auth=auth
)
assert config.base_url == "http://localhost:8088/api/v1"
# Test without /api/v1
config = SupersetConfig(
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_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
) )
assert env.id == "test-id"
assert env.name == "test-env"
assert env.url == "http://localhost:8088/api/v1"
# [/DEF:test_superset_config_url_normalization:Function]
# [/DEF:test_superset_config_invalid_url:Function]

View File

@@ -1,163 +0,0 @@
# [DEF:backup_script:Module]
#
# @SEMANTICS: backup, superset, automation, dashboard
# @PURPOSE: Этот модуль отвечает за автоматизированное резервное копирование дашбордов Superset.
# @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils
# @PUBLIC_API: BackupConfig, backup_dashboards, main
# [SECTION: IMPORTS]
import logging
import sys
from pathlib import Path
from dataclasses import dataclass,field
from requests.exceptions import RequestException
from superset_tool.client import SupersetClient
from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.fileio import (
save_and_unpack_dashboard,
archive_exports,
sanitize_filename,
consolidate_archive_folders,
remove_empty_directories,
RetentionPolicy
)
from superset_tool.utils.init_clients import setup_clients
# [/SECTION]
# [DEF:BackupConfig:DataClass]
# @PURPOSE: Хранит конфигурацию для процесса бэкапа.
@dataclass
class BackupConfig:
"""Конфигурация для процесса бэкапа."""
consolidate: bool = True
rotate_archive: bool = True
clean_folders: bool = True
retention_policy: RetentionPolicy = field(default_factory=RetentionPolicy)
# [/DEF:BackupConfig]
# [DEF:backup_dashboards:Function]
# @PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта.
# @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`.
# @PRE: `env_name` должен быть строкой, обозначающей окружение.
# @PRE: `backup_root` должен быть валидным путем к корневой директории бэкапа.
# @POST: Дашборды экспортируются и сохраняются. Ошибки экспорта логируются и не приводят к остановке скрипта.
# @RELATION: CALLS -> client.get_dashboards
# @RELATION: CALLS -> client.export_dashboard
# @RELATION: CALLS -> save_and_unpack_dashboard
# @RELATION: CALLS -> archive_exports
# @RELATION: CALLS -> consolidate_archive_folders
# @RELATION: CALLS -> remove_empty_directories
# @PARAM: client (SupersetClient) - Клиент для доступа к API Superset.
# @PARAM: env_name (str) - Имя окружения (e.g., 'PROD').
# @PARAM: backup_root (Path) - Корневая директория для сохранения бэкапов.
# @PARAM: logger (SupersetLogger) - Инстанс логгера.
# @PARAM: config (BackupConfig) - Конфигурация процесса бэкапа.
# @RETURN: bool - `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
def backup_dashboards(
client: SupersetClient,
env_name: str,
backup_root: Path,
logger: SupersetLogger,
config: BackupConfig
) -> bool:
logger.info(f"[backup_dashboards][Entry] Starting backup for {env_name}.")
try:
dashboard_count, dashboard_meta = client.get_dashboards()
logger.info(f"[backup_dashboards][Progress] Found {dashboard_count} dashboards to export in {env_name}.")
if dashboard_count == 0:
return True
success_count = 0
for db in dashboard_meta:
dashboard_id = db.get('id')
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
if not dashboard_id:
continue
try:
dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}")
dashboard_dir = backup_root / env_name / dashboard_base_dir_name
dashboard_dir.mkdir(parents=True, exist_ok=True)
zip_content, filename = client.export_dashboard(dashboard_id)
save_and_unpack_dashboard(
zip_content=zip_content,
original_filename=filename,
output_dir=dashboard_dir,
unpack=False,
logger=logger
)
if config.rotate_archive:
archive_exports(str(dashboard_dir), policy=config.retention_policy, logger=logger)
success_count += 1
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
logger.error(f"[backup_dashboards][Failure] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True)
continue
if config.consolidate:
consolidate_archive_folders(backup_root / env_name , logger=logger)
if config.clean_folders:
remove_empty_directories(str(backup_root / env_name), logger=logger)
logger.info(f"[backup_dashboards][CoherenceCheck:Passed] Backup logic completed.")
return success_count == dashboard_count
except (RequestException, IOError) as e:
logger.critical(f"[backup_dashboards][Failure] Fatal error during backup for {env_name}: {e}", exc_info=True)
return False
# [/DEF:backup_dashboards]
# [DEF:main:Function]
# @PURPOSE: Основная точка входа для запуска процесса резервного копирования.
# @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> backup_dashboards
# @RETURN: int - Код выхода (0 - успех, 1 - ошибка).
def main() -> int:
log_dir = Path("P:\\Superset\\010 Бекапы\\Logs")
logger = SupersetLogger(log_dir=log_dir, level=logging.INFO, console=True)
logger.info("[main][Entry] Starting Superset backup process.")
exit_code = 0
try:
clients = setup_clients(logger)
superset_backup_repo = Path("P:\\Superset\\010 Бекапы")
superset_backup_repo.mkdir(parents=True, exist_ok=True)
results = {}
environments = ['dev', 'sbx', 'prod', 'preprod']
backup_config = BackupConfig(rotate_archive=True)
for env in environments:
try:
results[env] = backup_dashboards(
clients[env],
env.upper(),
superset_backup_repo,
logger=logger,
config=backup_config
)
except Exception as env_error:
logger.critical(f"[main][Failure] Critical error for environment {env}: {env_error}", exc_info=True)
results[env] = False
if not all(results.values()):
exit_code = 1
except (RequestException, IOError) as e:
logger.critical(f"[main][Failure] Fatal error in main execution: {e}", exc_info=True)
exit_code = 1
logger.info("[main][Exit] Superset backup process finished.")
return exit_code
# [/DEF:main]
if __name__ == "__main__":
sys.exit(main())
# [/DEF:backup_script]

View File

@@ -1,79 +0,0 @@
# [DEF:debug_db_api:Module]
#
# @SEMANTICS: debug, api, database, script
# @PURPOSE: Скрипт для отладки структуры ответа API баз данных.
# @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils
# @PUBLIC_API: debug_database_api
# [SECTION: IMPORTS]
import json
import logging
from superset_tool.client import SupersetClient
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.logger import SupersetLogger
# [/SECTION]
# [DEF:debug_database_api:Function]
# @PURPOSE: Отладка структуры ответа API баз данных.
# @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> client.get_databases
def debug_database_api():
logger = SupersetLogger(name="debug_db_api", level=logging.DEBUG)
# Инициализируем клиенты
clients = setup_clients(logger)
# Log JWT bearer tokens for each client
for env_name, client in clients.items():
try:
# Ensure authentication (access token fetched via headers property)
_ = client.headers
token = client.network._tokens.get("access_token")
logger.info(f"[debug_database_api][Token] Bearer token for {env_name}: {token}")
except Exception as exc:
logger.error(f"[debug_database_api][Token] Failed to retrieve token for {env_name}: {exc}", exc_info=True)
# Проверяем доступные окружения
print("Доступные окружения:")
for env_name, client in clients.items():
print(f" {env_name}: {client.config.base_url}")
# Выбираем два окружения для тестирования
if len(clients) < 2:
print("Недостаточно окружений для тестирования")
return
env_names = list(clients.keys())[:2]
from_env, to_env = env_names[0], env_names[1]
from_client = clients[from_env]
to_client = clients[to_env]
print(f"\nТестируем API для окружений: {from_env} -> {to_env}")
try:
# Получаем список баз данных из первого окружения
print(f"\nПолучаем список БД из {from_env}:")
count, dbs = from_client.get_databases()
print(f"Найдено {count} баз данных")
print("Полный ответ API:")
print(json.dumps({"count": count, "result": dbs}, indent=2, ensure_ascii=False))
# Получаем список баз данных из второго окружения
print(f"\nПолучаем список БД из {to_env}:")
count, dbs = to_client.get_databases()
print(f"Найдено {count} баз данных")
print("Полный ответ API:")
print(json.dumps({"count": count, "result": dbs}, indent=2, ensure_ascii=False))
except Exception as e:
print(f"Ошибка при тестировании API: {e}")
import traceback
traceback.print_exc()
# [/DEF:debug_database_api]
if __name__ == "__main__":
debug_database_api()
# [/DEF:debug_db_api]

View File

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

View File

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

View File

@@ -7,6 +7,9 @@
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": {
"date-fns": "^4.1.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.2", "@sveltejs/kit": "^2.49.2",
@@ -909,7 +912,6 @@
"integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
@@ -949,7 +951,6 @@
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"debug": "^4.4.1", "debug": "^4.4.1",
@@ -1003,7 +1004,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1152,7 +1152,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -1279,6 +1278,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1600,7 +1609,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -1838,7 +1846,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2211,7 +2218,6 @@
"integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2335,7 +2341,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2417,7 +2422,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -2511,7 +2515,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

View File

@@ -17,5 +17,8 @@
"svelte": "^5.43.8", "svelte": "^5.43.8",
"tailwindcss": "^3.0.0", "tailwindcss": "^3.0.0",
"vite": "^7.2.4" "vite": "^7.2.4"
},
"dependencies": {
"date-fns": "^4.1.0"
} }
} }

View File

@@ -1,113 +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.
* @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]
// [DEF:navigate:Function]
/**
* @purpose Changes the current page and resets state.
* @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]
</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] -->

View File

@@ -61,6 +61,8 @@
// [DEF:handleSort:Function] // [DEF:handleSort:Function]
// @PURPOSE: Toggles sort direction or changes sort column. // @PURPOSE: Toggles sort direction or changes sort column.
// @PRE: column name is provided.
// @POST: sortColumn and sortDirection state updated.
function handleSort(column: keyof DashboardMetadata) { function handleSort(column: keyof DashboardMetadata) {
if (sortColumn === column) { if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc"; sortDirection = sortDirection === "asc" ? "desc" : "asc";
@@ -69,10 +71,12 @@
sortDirection = "asc"; sortDirection = "asc";
} }
} }
// [/DEF:handleSort] // [/DEF:handleSort:Function]
// [DEF:handleSelectionChange:Function] // [DEF:handleSelectionChange:Function]
// @PURPOSE: Handles individual checkbox changes. // @PURPOSE: Handles individual checkbox changes.
// @PRE: dashboard ID and checked status provided.
// @POST: selectedIds array updated and selectionChanged event dispatched.
function handleSelectionChange(id: number, checked: boolean) { function handleSelectionChange(id: number, checked: boolean) {
let newSelected = [...selectedIds]; let newSelected = [...selectedIds];
if (checked) { if (checked) {
@@ -83,10 +87,12 @@
selectedIds = newSelected; selectedIds = newSelected;
dispatch('selectionChanged', newSelected); dispatch('selectionChanged', newSelected);
} }
// [/DEF:handleSelectionChange] // [/DEF:handleSelectionChange:Function]
// [DEF:handleSelectAll:Function] // [DEF:handleSelectAll:Function]
// @PURPOSE: Handles select all checkbox. // @PURPOSE: Handles select all checkbox.
// @PRE: checked status provided.
// @POST: selectedIds array updated for all paginated items and event dispatched.
function handleSelectAll(checked: boolean) { function handleSelectAll(checked: boolean) {
let newSelected = [...selectedIds]; let newSelected = [...selectedIds];
if (checked) { if (checked) {
@@ -101,16 +107,18 @@
selectedIds = newSelected; selectedIds = newSelected;
dispatch('selectionChanged', newSelected); dispatch('selectionChanged', newSelected);
} }
// [/DEF:handleSelectAll] // [/DEF:handleSelectAll:Function]
// [DEF:goToPage:Function] // [DEF:goToPage:Function]
// @PURPOSE: Changes current page. // @PURPOSE: Changes current page.
// @PRE: page index is provided.
// @POST: currentPage state updated if within valid range.
function goToPage(page: number) { function goToPage(page: number) {
if (page >= 0 && page < totalPages) { if (page >= 0 && page < totalPages) {
currentPage = page; currentPage = page;
} }
} }
// [/DEF:goToPage] // [/DEF:goToPage:Function]
</script> </script>
@@ -202,4 +210,4 @@
/* Component styles */ /* Component styles */
</style> </style>
<!-- [/DEF:DashboardGrid] --> <!-- [/DEF:DashboardGrid:Component] -->

View File

@@ -23,16 +23,20 @@
// [DEF:handleSubmit:Function] // [DEF:handleSubmit:Function]
/** /**
* @purpose Dispatches the submit event with the form data. * @purpose Dispatches the submit event with the form data.
* @pre formData contains user input.
* @post 'submit' event is dispatched with formData.
*/ */
function handleSubmit() { function handleSubmit() {
console.log("[DynamicForm][Action] Submitting form data.", { formData }); console.log("[DynamicForm][Action] Submitting form data.", { formData });
dispatch('submit', formData); dispatch('submit', formData);
} }
// [/DEF:handleSubmit] // [/DEF:handleSubmit:Function]
// [DEF:initializeForm:Function] // [DEF:initializeForm:Function]
/** /**
* @purpose Initialize form data with default values from the schema. * @purpose Initialize form data with default values from the schema.
* @pre schema is provided and contains properties.
* @post formData is initialized with default values or empty strings.
*/ */
function initializeForm() { function initializeForm() {
if (schema && schema.properties) { if (schema && schema.properties) {
@@ -41,7 +45,7 @@
} }
} }
} }
// [/DEF:initializeForm] // [/DEF:initializeForm:Function]
initializeForm(); initializeForm();
</script> </script>
@@ -85,4 +89,4 @@
</form> </form>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<!-- [/DEF:DynamicForm] --> <!-- [/DEF:DynamicForm:Component] -->

View File

@@ -24,6 +24,8 @@
// [DEF:handleSelect:Function] // [DEF:handleSelect:Function]
/** /**
* @purpose Dispatches the selection change event. * @purpose Dispatches the selection change event.
* @pre event.target must be an HTMLSelectElement.
* @post selectedId is updated and 'change' event is dispatched.
* @param {Event} event - The change event from the select element. * @param {Event} event - The change event from the select element.
*/ */
function handleSelect(event: Event) { function handleSelect(event: Event) {
@@ -31,7 +33,7 @@
selectedId = target.value; selectedId = target.value;
dispatch('change', { id: selectedId }); dispatch('change', { id: selectedId });
} }
// [/DEF:handleSelect] // [/DEF:handleSelect:Function]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
@@ -55,4 +57,4 @@
/* Component specific styles */ /* Component specific styles */
</style> </style>
<!-- [/DEF:EnvSelector] --> <!-- [/DEF:EnvSelector:Component] -->

View File

@@ -1,3 +1,10 @@
<!-- [DEF:Footer:Component] -->
<!--
@SEMANTICS: footer, layout, copyright
@PURPOSE: Displays the application footer with copyright information.
@LAYER: UI
-->
<footer class="bg-white border-t p-4 mt-8 text-center text-gray-500 text-sm"> <footer class="bg-white border-t p-4 mt-8 text-center text-gray-500 text-sm">
&copy; 2025 Superset Tools. All rights reserved. &copy; 2025 Superset Tools. All rights reserved.
</footer> </footer>
<!-- [/DEF:Footer:Component] -->

View File

@@ -25,20 +25,24 @@
// [DEF:updateMapping:Function] // [DEF:updateMapping:Function]
/** /**
* @purpose Updates a mapping for a specific source database. * @purpose Updates a mapping for a specific source database.
* @pre sourceUuid and targetUuid are provided.
* @post 'update' event is dispatched.
*/ */
function updateMapping(sourceUuid: string, targetUuid: string) { function updateMapping(sourceUuid: string, targetUuid: string) {
dispatch('update', { sourceUuid, targetUuid }); dispatch('update', { sourceUuid, targetUuid });
} }
// [/DEF:updateMapping] // [/DEF:updateMapping:Function]
// [DEF:getSuggestion:Function] // [DEF:getSuggestion:Function]
/** /**
* @purpose Finds a suggestion for a source database. * @purpose Finds a suggestion for a source database.
* @pre sourceUuid is provided.
* @post Returns matching suggestion object or undefined.
*/ */
function getSuggestion(sourceUuid: string) { function getSuggestion(sourceUuid: string) {
return suggestions.find(s => s.source_db_uuid === sourceUuid); return suggestions.find(s => s.source_db_uuid === sourceUuid);
} }
// [/DEF:getSuggestion] // [/DEF:getSuggestion:Function]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
@@ -91,4 +95,4 @@
/* Component specific styles */ /* Component specific styles */
</style> </style>
<!-- [/DEF:MappingTable] --> <!-- [/DEF:MappingTable:Component] -->

View File

@@ -24,6 +24,9 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
// [DEF:resolve:Function] // [DEF:resolve:Function]
// @PURPOSE: Dispatches the resolution event with the selected mapping.
// @PRE: selectedTargetUuid must be set.
// @POST: 'resolve' event is dispatched and modal is hidden.
function resolve() { function resolve() {
if (!selectedTargetUuid) return; if (!selectedTargetUuid) return;
dispatch('resolve', { dispatch('resolve', {
@@ -33,14 +36,17 @@
}); });
show = false; show = false;
} }
// [/DEF:resolve] // [/DEF:resolve:Function]
// [DEF:cancel:Function] // [DEF:cancel:Function]
// @PURPOSE: Cancels the mapping resolution modal.
// @PRE: Modal is open.
// @POST: 'cancel' event is dispatched and modal is hidden.
function cancel() { function cancel() {
dispatch('cancel'); dispatch('cancel');
show = false; show = false;
} }
// [/DEF:cancel] // [/DEF:cancel:Function]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
@@ -109,4 +115,4 @@
/* Modal specific styles */ /* Modal specific styles */
</style> </style>
<!-- [/DEF:MissingMappingModal] --> <!-- [/DEF:MissingMappingModal:Component] -->

View File

@@ -1,3 +1,10 @@
<!-- [DEF:Navbar:Component] -->
<!--
@SEMANTICS: navbar, navigation, header, layout
@PURPOSE: Main navigation bar for the application.
@LAYER: UI
@RELATION: USES -> $app/stores
-->
<script> <script>
import { page } from '$app/stores'; import { page } from '$app/stores';
</script> </script>
@@ -23,10 +30,30 @@
Migration Migration
</a> </a>
<a <a
href="/settings" href="/tasks"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname === '/settings' ? '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' : ''}"
> >
Settings 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 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
<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">
<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
</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 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/connections" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Connections</a>
</div>
</div>
</nav> </nav>
</header> </header>
<!-- [/DEF:Navbar:Component] -->

View File

@@ -18,6 +18,10 @@
let passwords = {}; let passwords = {};
let submitting = false; let submitting = false;
// [DEF:handleSubmit:Function]
// @PURPOSE: Validates and dispatches the passwords to resume the task.
// @PRE: All database passwords must be entered.
// @POST: 'resume' event is dispatched with passwords.
function handleSubmit() { function handleSubmit() {
if (submitting) return; if (submitting) return;
@@ -32,11 +36,17 @@
dispatch('resume', { passwords }); dispatch('resume', { passwords });
// Reset submitting state is handled by parent or on close // Reset submitting state is handled by parent or on close
} }
// [/DEF:handleSubmit:Function]
// [DEF:handleCancel:Function]
// @PURPOSE: Cancels the password prompt.
// @PRE: Modal is open.
// @POST: 'cancel' event is dispatched and show is set to false.
function handleCancel() { function handleCancel() {
dispatch('cancel'); dispatch('cancel');
show = false; show = false;
} }
// [/DEF:handleCancel:Function]
// Reset passwords when modal opens/closes // Reset passwords when modal opens/closes
$: if (!show) { $: if (!show) {
@@ -120,4 +130,4 @@
</div> </div>
</div> </div>
{/if} {/if}
<!-- [/DEF:PasswordPrompt] --> <!-- [/DEF:PasswordPrompt:Component] -->

View File

@@ -15,6 +15,10 @@
let error = ""; let error = "";
let interval; let interval;
// [DEF:fetchTasks:Function]
// @PURPOSE: Fetches the list of recent tasks from the API.
// @PRE: None.
// @POST: tasks array is updated and selectedTask status synchronized.
async function fetchTasks() { async function fetchTasks() {
try { try {
const res = await fetch('/api/tasks?limit=10'); const res = await fetch('/api/tasks?limit=10');
@@ -41,7 +45,12 @@
loading = false; loading = false;
} }
} }
// [/DEF:fetchTasks:Function]
// [DEF:clearTasks:Function]
// @PURPOSE: Clears tasks from the history, optionally filtered by status.
// @PRE: User confirms deletion via prompt.
// @POST: Tasks are deleted from backend and list is re-fetched.
async function clearTasks(status = null) { async function clearTasks(status = null) {
if (!confirm('Are you sure you want to clear tasks?')) return; if (!confirm('Are you sure you want to clear tasks?')) return;
try { try {
@@ -57,7 +66,12 @@
error = e.message; error = e.message;
} }
} }
// [/DEF:clearTasks:Function]
// [DEF:selectTask:Function]
// @PURPOSE: Selects a task and fetches its full details.
// @PRE: task object is provided.
// @POST: selectedTask store is updated with full task details.
async function selectTask(task) { async function selectTask(task) {
try { try {
// Fetch the full task details (including logs) before setting it as selected // Fetch the full task details (including logs) before setting it as selected
@@ -74,7 +88,12 @@
selectedTask.set(task); selectedTask.set(task);
} }
} }
// [/DEF:selectTask:Function]
// [DEF:getStatusColor:Function]
// @PURPOSE: Returns the CSS color class for a given task status.
// @PRE: status string is provided.
// @POST: Returns tailwind color class string.
function getStatusColor(status) { function getStatusColor(status) {
switch (status) { switch (status) {
case 'SUCCESS': return 'bg-green-100 text-green-800'; case 'SUCCESS': return 'bg-green-100 text-green-800';
@@ -85,15 +104,26 @@
default: return 'bg-gray-100 text-gray-800'; default: return 'bg-gray-100 text-gray-800';
} }
} }
// [/DEF:getStatusColor:Function]
// [DEF:onMount:Function]
// @PURPOSE: Initializes the component by fetching tasks and starting polling.
// @PRE: Component is mounting.
// @POST: Tasks are fetched and 5s polling interval is started.
onMount(() => { onMount(() => {
fetchTasks(); fetchTasks();
interval = setInterval(fetchTasks, 5000); // Poll every 5s interval = setInterval(fetchTasks, 5000); // Poll every 5s
}); });
// [/DEF:onMount:Function]
// [DEF:onDestroy:Function]
// @PURPOSE: Cleans up the polling interval when the component is destroyed.
// @PRE: Component is being destroyed.
// @POST: Polling interval is cleared.
onDestroy(() => { onDestroy(() => {
clearInterval(interval); clearInterval(interval);
}); });
// [/DEF:onDestroy:Function]
</script> </script>
<div class="bg-white shadow overflow-hidden sm:rounded-lg mb-8"> <div class="bg-white shadow overflow-hidden sm:rounded-lg mb-8">
@@ -176,4 +206,4 @@
</ul> </ul>
{/if} {/if}
</div> </div>
<!-- [/DEF:TaskHistory] --> <!-- [/DEF:TaskHistory:Component] -->

View File

@@ -0,0 +1,109 @@
<!-- [DEF:TaskList:Component] -->
<!--
@SEMANTICS: tasks, list, status, history
@PURPOSE: Displays a list of tasks with their status and execution details.
@LAYER: Component
@RELATION: USES -> api.js
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { formatDistanceToNow } from 'date-fns';
export let tasks: Array<any> = [];
export let loading: boolean = false;
const dispatch = createEventDispatcher();
// [DEF:getStatusColor:Function]
// @PURPOSE: Returns the CSS color class for a given task status.
// @PRE: status string is provided.
// @POST: Returns tailwind color class string.
function getStatusColor(status: string) {
switch (status) {
case 'SUCCESS': return 'bg-green-100 text-green-800';
case 'FAILED': return 'bg-red-100 text-red-800';
case 'RUNNING': return 'bg-blue-100 text-blue-800 animate-pulse';
case 'PENDING': return 'bg-gray-100 text-gray-800';
case 'AWAITING_INPUT':
case 'AWAITING_MAPPING': return 'bg-yellow-100 text-yellow-800';
default: return 'bg-gray-100 text-gray-800';
}
}
// [/DEF:getStatusColor:Function]
// [DEF:formatTime:Function]
// @PURPOSE: Formats a date string using date-fns.
// @PRE: dateStr is a valid date string or null.
// @POST: Returns human-readable relative time string.
function formatTime(dateStr: string | null) {
if (!dateStr) return 'N/A';
try {
return formatDistanceToNow(new Date(dateStr), { addSuffix: true });
} catch (e) {
return 'Invalid date';
}
}
// [/DEF:formatTime:Function]
// [DEF:handleTaskClick:Function]
// @PURPOSE: Dispatches a select event when a task is clicked.
// @PRE: taskId is provided.
// @POST: 'select' event is dispatched with task ID.
function handleTaskClick(taskId: string) {
dispatch('select', { id: taskId });
}
// [/DEF:handleTaskClick:Function]
</script>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
{#if loading && tasks.length === 0}
<div class="p-4 text-center text-gray-500">Loading tasks...</div>
{:else if tasks.length === 0}
<div class="p-4 text-center text-gray-500">No tasks found.</div>
{:else}
<ul class="divide-y divide-gray-200">
{#each tasks as task (task.id)}
<li>
<button
class="block hover:bg-gray-50 w-full text-left transition duration-150 ease-in-out focus:outline-none"
on:click={() => handleTaskClick(task.id)}
>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-blue-600 truncate">
{task.plugin_id.toUpperCase()}
<span class="ml-2 text-xs text-gray-400 font-mono">{task.id.substring(0, 8)}</span>
</div>
<div class="ml-2 flex-shrink-0 flex">
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {getStatusColor(task.status)}">
{task.status}
</p>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-gray-500">
{#if task.params?.environment_id || task.params?.source_env_id}
<span class="mr-2">Env: {task.params.environment_id || task.params.source_env_id}</span>
{/if}
</p>
</div>
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
<svg class="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<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>
<p>
Started {formatTime(task.started_at)}
</p>
</div>
</div>
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- [/DEF:TaskList:Component] -->

View File

@@ -1,15 +1,16 @@
<!-- [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';
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,20 +23,39 @@
let autoScroll = true; let autoScroll = true;
let logContainer; let logContainer;
$: shouldShow = inline || show;
// [DEF:fetchLogs:Function]
/**
* @purpose Fetches logs for the current task.
* @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;
} }
} }
// [/DEF:fetchLogs:Function]
// [DEF:scrollToBottom:Function]
/**
* @purpose Scrolls the log container to the bottom.
* @pre logContainer element must be bound.
* @post logContainer scrollTop is set to scrollHeight.
*/
function scrollToBottom() { function scrollToBottom() {
if (logContainer) { if (logContainer) {
setTimeout(() => { setTimeout(() => {
@@ -43,7 +63,14 @@
}, 0); }, 0);
} }
} }
// [/DEF:scrollToBottom:Function]
// [DEF:handleScroll:Function]
/**
* @purpose Updates auto-scroll preference based on scroll position.
* @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
@@ -51,12 +78,26 @@
const atBottom = scrollHeight - scrollTop - clientHeight < 50; const atBottom = scrollHeight - scrollTop - clientHeight < 50;
autoScroll = atBottom; autoScroll = atBottom;
} }
// [/DEF:handleScroll:Function]
// [DEF:close:Function]
/**
* @purpose Closes the log viewer modal.
* @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;
} }
// [/DEF:close:Function]
// [DEF:getLogLevelColor:Function]
/**
* @purpose Returns the CSS color class for a given log level.
* @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';
@@ -66,9 +107,12 @@
default: return 'text-gray-800'; default: return 'text-gray-800';
} }
} }
// [/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 = "";
@@ -82,72 +126,121 @@
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
} }
// [DEF:onDestroy:Function]
/**
* @purpose Cleans up the polling interval.
* @pre Component is being destroyed.
* @post Polling interval is cleared.
*/
onDestroy(() => { onDestroy(() => {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
}); });
// [/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">
Task Logs <span class="text-sm text-gray-500 font-normal">({taskId})</span>
</h3>
<button on:click={fetchLogs} class="text-sm text-indigo-600 hover:text-indigo-900">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">Loading logs...</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">No logs available.</p>
{:else}
{#each logs as log}
<div class="mb-1 hover:bg-gray-100 p-1 rounded">
<span class="text-gray-400 text-xs mr-2">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}">
[{log.level}]
</span>
<span class="text-gray-800 break-words">
{log.message}
</span>
{#if log.context}
<div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto">
<pre>{JSON.stringify(log.context, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
{:else}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" on:click={close}></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full"> <div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full"> <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center" id="modal-title"> <h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center" id="modal-title">
<span>Task Logs <span class="text-sm text-gray-500 font-normal">({taskId})</span></span> <span>Task Logs <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 on:click={fetchLogs} class="text-sm text-indigo-600 hover:text-indigo-900">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">Loading logs...</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">No logs available.</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
<button type="button"
type="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"
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" on:click={close}
on:click={close} >
> Close
Close </button>
</button> </div>
</div> </div>
</div> </div>
</div> </div>
</div> {/if}
{/if} {/if}
<!-- [/DEF:TaskLogViewer] --> <!-- [/DEF:TaskLogViewer:Component] -->

View File

@@ -38,6 +38,8 @@
// [DEF:connect:Function] // [DEF:connect:Function]
/** /**
* @purpose Establishes WebSocket connection with exponential backoff. * @purpose Establishes WebSocket connection with exponential backoff.
* @pre selectedTask must be set in the store.
* @post WebSocket instance created and listeners attached.
*/ */
function connect() { function connect() {
const task = get(selectedTask); const task = get(selectedTask);
@@ -127,8 +129,12 @@
} }
}; };
} }
// [/DEF:connect] // [/DEF:connect:Function]
// [DEF:fetchTargetDatabases:Function]
// @PURPOSE: Fetches the list of databases in the target environment.
// @PRE: task must be selected and have a target environment parameter.
// @POST: targetDatabases array is populated with database objects.
async function fetchTargetDatabases() { async function fetchTargetDatabases() {
const task = get(selectedTask); const task = get(selectedTask);
if (!task || !task.params.to_env) return; if (!task || !task.params.to_env) return;
@@ -147,7 +153,12 @@
console.error('Failed to fetch target databases', e); console.error('Failed to fetch target databases', e);
} }
} }
// [/DEF:fetchTargetDatabases:Function]
// [DEF:handleMappingResolve:Function]
// @PURPOSE: Handles the resolution of a missing database mapping.
// @PRE: event.detail contains sourceDbUuid, targetDbUuid, and targetDbName.
// @POST: Mapping is saved and task is resumed.
async function handleMappingResolve(event) { async function handleMappingResolve(event) {
const task = get(selectedTask); const task = get(selectedTask);
const { sourceDbUuid, targetDbUuid, targetDbName } = event.detail; const { sourceDbUuid, targetDbUuid, targetDbName } = event.detail;
@@ -187,7 +198,12 @@
addToast('Failed to resolve mapping: ' + e.message, 'error'); addToast('Failed to resolve mapping: ' + e.message, 'error');
} }
} }
// [/DEF:handleMappingResolve:Function]
// [DEF:handlePasswordResume:Function]
// @PURPOSE: Handles the submission of database passwords to resume a task.
// @PRE: event.detail contains passwords dictionary.
// @POST: Task resume endpoint is called with passwords.
async function handlePasswordResume(event) { async function handlePasswordResume(event) {
const task = get(selectedTask); const task = get(selectedTask);
const { passwords } = event.detail; const { passwords } = event.detail;
@@ -206,7 +222,12 @@
addToast('Failed to resume task: ' + e.message, 'error'); addToast('Failed to resume task: ' + e.message, 'error');
} }
} }
// [/DEF:handlePasswordResume:Function]
// [DEF:startDataTimeout:Function]
// @PURPOSE: Starts a timeout to detect when the log stream has stalled.
// @PRE: None.
// @POST: dataTimeout is set to check connection status after 5s.
function startDataTimeout() { function startDataTimeout() {
waitingForData = false; waitingForData = false;
dataTimeout = setTimeout(() => { dataTimeout = setTimeout(() => {
@@ -215,14 +236,23 @@
} }
}, 5000); }, 5000);
} }
// [/DEF:startDataTimeout:Function]
// [DEF:resetDataTimeout:Function]
// @PURPOSE: Resets the data stall timeout.
// @PRE: dataTimeout must be active.
// @POST: dataTimeout is cleared and restarted.
function resetDataTimeout() { function resetDataTimeout() {
clearTimeout(dataTimeout); clearTimeout(dataTimeout);
waitingForData = false; waitingForData = false;
startDataTimeout(); startDataTimeout();
} }
// [/DEF:resetDataTimeout:Function]
// [DEF:onMount:Function] // [DEF:onMount:Function]
// @PURPOSE: Initializes the component and subscribes to task selection changes.
// @PRE: Svelte component is mounting.
// @POST: Store subscription is created and returned for cleanup.
onMount(() => { onMount(() => {
// Subscribe to selectedTask changes // Subscribe to selectedTask changes
const unsubscribe = selectedTask.subscribe(task => { const unsubscribe = selectedTask.subscribe(task => {
@@ -246,11 +276,13 @@
}); });
return unsubscribe; return unsubscribe;
}); });
// [/DEF:onMount] // [/DEF:onMount:Function]
// [DEF:onDestroy:Function] // [DEF:onDestroy:Function]
/** /**
* @purpose Close WebSocket connection when the component is destroyed. * @purpose Close WebSocket connection when the component is destroyed.
* @pre Component is being destroyed.
* @post WebSocket is closed and timeouts are cleared.
*/ */
onDestroy(() => { onDestroy(() => {
clearTimeout(reconnectTimeout); clearTimeout(reconnectTimeout);
@@ -260,7 +292,7 @@
ws.close(); ws.close();
} }
}); });
// [/DEF:onDestroy] // [/DEF:onDestroy:Function]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
@@ -360,4 +392,4 @@
/> />
<!-- [/SECTION] --> <!-- [/SECTION] -->
<!-- [/DEF:TaskRunner] --> <!-- [/DEF:TaskRunner:Component] -->

View File

@@ -28,4 +28,4 @@
</div> </div>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<!-- [/DEF:Toast] --> <!-- [/DEF:Toast:Component] -->

View File

@@ -0,0 +1,108 @@
<!-- [DEF:ConnectionForm:Component] -->
<!--
@SEMANTICS: connection, form, settings
@PURPOSE: UI component for creating a new database connection configuration.
@LAYER: UI
@RELATION: USES -> frontend/src/services/connectionService.js
-->
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import { createConnection } from '../../services/connectionService.js';
import { addToast } from '../../lib/toasts.js';
// [/SECTION]
const dispatch = createEventDispatcher();
let name = '';
let type = 'postgres';
let host = '';
let port = 5432;
let database = '';
let username = '';
let password = '';
let isSubmitting = false;
// [DEF:handleSubmit:Function]
// @PURPOSE: Submits the connection form to the backend.
// @PRE: All required fields (name, host, database, username, password) must be filled.
// @POST: A new connection is created via the connection service and a success event is dispatched.
async function handleSubmit() {
if (!name || !host || !database || !username || !password) {
addToast('Please fill in all required fields', 'warning');
return;
}
isSubmitting = true;
try {
const newConnection = await createConnection({
name, type, host, port, database, username, password
});
addToast('Connection created successfully', 'success');
dispatch('success', newConnection);
resetForm();
} catch (e) {
addToast(e.message, 'error');
} finally {
isSubmitting = false;
}
}
// [/DEF:handleSubmit:Function]
// [DEF:resetForm:Function]
/* @PURPOSE: Resets the connection form fields to their default values.
@PRE: None.
@POST: All form input variables are reset.
*/
function resetForm() {
name = '';
host = '';
port = 5432;
database = '';
username = '';
password = '';
}
// [/DEF:resetForm:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<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">Add New Connection</h3>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="conn-name" class="block text-sm font-medium text-gray-700">Connection Name</label>
<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" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<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>
<div>
<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 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">
{isSubmitting ? 'Creating...' : 'Create Connection'}
</button>
</div>
</form>
</div>
<!-- [/SECTION] -->
<!-- [/DEF:ConnectionForm:Component] -->

View File

@@ -0,0 +1,88 @@
<!-- [DEF:ConnectionList:Component] -->
<!--
@SEMANTICS: connection, list, settings
@PURPOSE: UI component for listing and deleting saved database connection configurations.
@LAYER: UI
@RELATION: USES -> frontend/src/services/connectionService.js
-->
<script>
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte';
import { getConnections, deleteConnection } from '../../services/connectionService.js';
import { addToast } from '../../lib/toasts.js';
// [/SECTION]
const dispatch = createEventDispatcher();
let connections = [];
let isLoading = true;
// [DEF:fetchConnections:Function]
// @PURPOSE: Fetches the list of connections from the backend.
// @PRE: None.
// @POST: connections array is populated.
async function fetchConnections() {
isLoading = true;
try {
connections = await getConnections();
} catch (e) {
addToast('Failed to fetch connections', 'error');
} finally {
isLoading = false;
}
}
// [/DEF:fetchConnections:Function]
// [DEF:handleDelete:Function]
// @PURPOSE: Deletes a connection configuration.
// @PRE: id is provided and user confirms deletion.
// @POST: Connection is deleted from backend and list is reloaded.
async function handleDelete(id) {
if (!confirm('Are you sure you want to delete this connection?')) return;
try {
await deleteConnection(id);
addToast('Connection deleted', 'success');
await fetchConnections();
} catch (e) {
addToast(e.message, 'error');
}
}
// [/DEF:handleDelete:Function]
onMount(fetchConnections);
// Expose fetchConnections to parent
export { fetchConnections };
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="bg-white shadow overflow-hidden sm:rounded-md border border-gray-200">
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200">
<h3 class="text-lg leading-6 font-medium text-gray-900">Saved Connections</h3>
</div>
<ul class="divide-y divide-gray-200">
{#if isLoading}
<li class="p-4 text-center text-gray-500">Loading...</li>
{:else if connections.length === 0}
<li class="p-8 text-center text-gray-500 italic">No connections saved yet.</li>
{:else}
{#each connections as conn}
<li class="p-4 flex items-center justify-between hover:bg-gray-50">
<div>
<div class="text-sm font-medium text-indigo-600 truncate">{conn.name}</div>
<div class="text-xs text-gray-500">{conn.type}://{conn.username}@{conn.host}:{conn.port}/{conn.database}</div>
</div>
<button
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
</button>
</li>
{/each}
{/if}
</ul>
</div>
<!-- [/SECTION] -->
<!-- [/DEF:ConnectionList:Component] -->

View File

@@ -0,0 +1,190 @@
<!-- [DEF:DebugTool:Component] -->
<!--
@SEMANTICS: debug, tool, api, structure
@PURPOSE: UI component for system diagnostics and debugging API responses.
@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 action = 'test-db-api';
let selectedEnv = '';
let datasetId = '';
let sourceEnv = '';
let targetEnv = '';
let isRunning = false;
let results = null;
let pollInterval;
// [DEF:fetchEnvironments:Function]
/**
* @purpose Fetches available environments.
* @pre API is available.
* @post envs variable is populated.
* @returns {Promise<void>}
*/
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:handleRunDebug:Function]
/**
* @purpose Triggers the debug task.
* @pre Required fields are selected.
* @post Task is started and polling begins.
* @returns {Promise<void>}
*/
async function handleRunDebug() {
isRunning = true;
results = null;
try {
let params = { action };
if (action === 'test-db-api') {
if (!sourceEnv || !targetEnv) {
addToast('Source and Target environments are required', 'warning');
isRunning = false;
return;
}
const sEnv = envs.find(e => e.id === sourceEnv);
const tEnv = envs.find(e => e.id === targetEnv);
params.source_env = sEnv.name;
params.target_env = tEnv.name;
} else {
if (!selectedEnv || !datasetId) {
addToast('Environment and Dataset ID are required', 'warning');
isRunning = false;
return;
}
const env = envs.find(e => e.id === selectedEnv);
params.env = env.name;
params.dataset_id = parseInt(datasetId);
}
const task = await runTask('system-debug', params);
selectedTask.set(task);
startPolling(task.id);
} catch (e) {
isRunning = false;
addToast(e.message, 'error');
}
}
// [/DEF:handleRunDebug:Function]
// [DEF:startPolling:Function]
/**
* @purpose Polls for task completion.
* @pre Task ID is valid.
* @post Polls until success/failure.
* @param {string} taskId - ID of the task.
* @returns {void}
*/
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('Debug task completed', 'success');
} else if (task.status === 'FAILED') {
clearInterval(pollInterval);
isRunning = false;
addToast('Debug task failed', 'error');
}
} catch (e) {
clearInterval(pollInterval);
isRunning = false;
}
}, 2000);
}
// [/DEF:startPolling:Function]
onMount(fetchEnvironments);
</script>
<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">System Diagnostics</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Debug Action</label>
<select bind:value={action} 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="test-db-api">Test Database API (Compare Envs)</option>
<option value="get-dataset-structure">Get Dataset Structure (JSON)</option>
</select>
</div>
{#if action === 'test-db-api'}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="src-env" class="block text-sm font-medium text-gray-700">Source Environment</label>
<select id="src-env" bind:value={sourceEnv} 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 Source --</option>
{#each envs as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<div>
<label for="tgt-env" class="block text-sm font-medium text-gray-700">Target Environment</label>
<select id="tgt-env" bind:value={targetEnv} 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 Target --</option>
{#each envs as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="debug-env" class="block text-sm font-medium text-gray-700">Environment</label>
<select id="debug-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="debug-ds-id" class="block text-sm font-medium text-gray-700">Dataset ID</label>
<input type="number" id="debug-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>
{/if}
<div class="mt-4 flex justify-end">
<button on:click={handleRunDebug} 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">
{isRunning ? 'Running...' : 'Run Diagnostics'}
</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 bg-gray-50 border-b border-gray-200">
<h3 class="text-lg leading-6 font-medium text-gray-900">Debug Output</h3>
</div>
<div class="p-4">
<pre class="text-xs text-gray-600 bg-gray-900 text-green-400 p-4 rounded-md overflow-x-auto h-96">{JSON.stringify(results, null, 2)}</pre>
</div>
</div>
{/if}
</div>
<!-- [/DEF:DebugTool:Component] -->

View File

@@ -0,0 +1,165 @@
<!-- [DEF:MapperTool:Component] -->
<!--
@SEMANTICS: mapper, tool, dataset, postgresql, excel
@PURPOSE: UI component for mapping dataset column verbose names using the MapperPlugin.
@LAYER: UI
@RELATION: USES -> frontend/src/services/toolsService.js
@RELATION: USES -> frontend/src/services/connectionService.js
-->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { runTask } from '../../services/toolsService.js';
import { getConnections } from '../../services/connectionService.js';
import { selectedTask } from '../../lib/stores.js';
import { addToast } from '../../lib/toasts.js';
// [/SECTION]
let envs = [];
let connections = [];
let selectedEnv = '';
let datasetId = '';
let source = 'postgres';
let selectedConnection = '';
let tableName = '';
let tableSchema = 'public';
let excelPath = '';
let isRunning = false;
// [DEF:fetchData:Function]
// @PURPOSE: Fetches environments and saved connections.
// @PRE: None.
// @POST: envs and connections arrays are populated.
async function fetchData() {
try {
const envsRes = await fetch('/api/environments');
envs = await envsRes.json();
connections = await getConnections();
} catch (e) {
addToast('Failed to fetch data', 'error');
}
}
// [/DEF:fetchData:Function]
// [DEF:handleRunMapper:Function]
// @PURPOSE: Triggers the MapperPlugin task.
// @PRE: selectedEnv and datasetId are set; source-specific fields are valid.
// @POST: Mapper task is started and selectedTask is updated.
async function handleRunMapper() {
if (!selectedEnv || !datasetId) {
addToast('Please fill in required fields', 'warning');
return;
}
if (source === 'postgres' && (!selectedConnection || !tableName)) {
addToast('Connection and Table Name are required for postgres source', 'warning');
return;
}
if (source === 'excel' && !excelPath) {
addToast('Excel path is required for excel source', 'warning');
return;
}
isRunning = true;
try {
const env = envs.find(e => e.id === selectedEnv);
const task = await runTask('dataset-mapper', {
env: env.name,
dataset_id: parseInt(datasetId),
source,
connection_id: selectedConnection,
table_name: tableName,
table_schema: tableSchema,
excel_path: excelPath
});
selectedTask.set(task);
addToast('Mapper task started', 'success');
} catch (e) {
addToast(e.message, 'error');
} finally {
isRunning = false;
}
}
// [/DEF:handleRunMapper:Function]
onMount(fetchData);
</script>
<!-- [SECTION: TEMPLATE] -->
<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">Dataset Column Mapper</h3>
<div class="space-y-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>
<label for="mapper-conn" class="block text-sm font-medium text-gray-700">Saved Connection</label>
<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">
<option value="" disabled>-- Select Connection --</option>
{#each connections as conn}
<option value={conn.id}>{conn.name}</option>
{/each}
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="mapper-table" class="block text-sm font-medium text-gray-700">Table Name</label>
<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" />
</div>
<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>
{: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">
<button
on:click={handleRunMapper}
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"
>
{isRunning ? 'Starting...' : 'Run Mapper'}
</button>
</div>
</div>
</div>
<!-- [/SECTION] -->
<!-- [/DEF:MapperTool:Component] -->

View File

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

View File

@@ -8,11 +8,12 @@ import { PUBLIC_WS_URL } from '$env/static/public';
const API_BASE_URL = '/api'; const API_BASE_URL = '/api';
/** // [DEF:getWsUrl:Function]
* Returns the WebSocket URL for a specific task, with fallback logic. // @PURPOSE: Returns the WebSocket URL for a specific task, with fallback logic.
* @param {string} taskId // @PRE: taskId is provided.
* @returns {string} // @POST: Returns valid WebSocket URL string.
*/ // @PARAM: taskId (string) - The ID of the task.
// @RETURN: string - The WebSocket URL.
export const getWsUrl = (taskId) => { export const getWsUrl = (taskId) => {
let baseUrl = PUBLIC_WS_URL; let baseUrl = PUBLIC_WS_URL;
if (!baseUrl) { if (!baseUrl) {
@@ -22,9 +23,12 @@ export const getWsUrl = (taskId) => {
} }
return `${baseUrl}/ws/logs/${taskId}`; return `${baseUrl}/ws/logs/${taskId}`;
}; };
// [/DEF:getWsUrl:Function]
// [DEF:fetchApi:Function] // [DEF:fetchApi:Function]
// @PURPOSE: Generic GET request wrapper. // @PURPOSE: Generic GET request wrapper.
// @PRE: endpoint string is provided.
// @POST: Returns Promise resolving to JSON data or throws on error.
// @PARAM: endpoint (string) - API endpoint. // @PARAM: endpoint (string) - API endpoint.
// @RETURN: Promise<any> - JSON response. // @RETURN: Promise<any> - JSON response.
async function fetchApi(endpoint) { async function fetchApi(endpoint) {
@@ -41,10 +45,12 @@ async function fetchApi(endpoint) {
throw error; throw error;
} }
} }
// [/DEF:fetchApi] // [/DEF:fetchApi:Function]
// [DEF:postApi:Function] // [DEF:postApi:Function]
// @PURPOSE: Generic POST request wrapper. // @PURPOSE: Generic POST request wrapper.
// @PRE: endpoint and body are provided.
// @POST: Returns Promise resolving to JSON data or throws on error.
// @PARAM: endpoint (string) - API endpoint. // @PARAM: endpoint (string) - API endpoint.
// @PARAM: body (object) - Request payload. // @PARAM: body (object) - Request payload.
// @RETURN: Promise<any> - JSON response. // @RETURN: Promise<any> - JSON response.
@@ -68,10 +74,12 @@ async function postApi(endpoint, body) {
throw error; throw error;
} }
} }
// [/DEF:postApi] // [/DEF:postApi:Function]
// [DEF:requestApi:Function] // [DEF:requestApi:Function]
// @PURPOSE: Generic request wrapper. // @PURPOSE: Generic request wrapper.
// @PRE: endpoint and method are provided.
// @POST: Returns Promise resolving to JSON data or throws on error.
async function requestApi(endpoint, method = 'GET', body = null) { async function requestApi(endpoint, method = 'GET', body = null) {
try { try {
console.log(`[api.requestApi][Action] ${method} to context={{'endpoint': '${endpoint}'}}`); console.log(`[api.requestApi][Action] ${method} to context={{'endpoint': '${endpoint}'}}`);
@@ -96,25 +104,30 @@ async function requestApi(endpoint, method = 'GET', body = null) {
throw error; throw error;
} }
} }
// [/DEF:requestApi:Function]
// [DEF:api:Data] // [DEF:api:Data]
// @PURPOSE: API client object with specific methods. // @PURPOSE: API client object with specific methods.
export const api = { export const api = {
getPlugins: () => fetchApi('/plugins/'), getPlugins: () => fetchApi('/plugins'),
getTasks: () => fetchApi('/tasks/'), getTasks: () => fetchApi('/tasks'),
getTask: (taskId) => fetchApi(`/tasks/${taskId}`), getTask: (taskId) => fetchApi(`/tasks/${taskId}`),
createTask: (pluginId, params) => postApi('/tasks/', { plugin_id: pluginId, params }), createTask: (pluginId, params) => postApi('/tasks', { plugin_id: pluginId, params }),
// Settings // Settings
getSettings: () => fetchApi('/settings/'), getSettings: () => fetchApi('/settings'),
updateGlobalSettings: (settings) => requestApi('/settings/global', 'PATCH', settings), updateGlobalSettings: (settings) => requestApi('/settings/global', 'PATCH', settings),
getEnvironments: () => fetchApi('/settings/environments'), getEnvironments: () => fetchApi('/settings/environments'),
addEnvironment: (env) => postApi('/settings/environments', env), addEnvironment: (env) => postApi('/settings/environments', env),
updateEnvironment: (id, env) => requestApi(`/settings/environments/${id}`, 'PUT', env), updateEnvironment: (id, env) => requestApi(`/settings/environments/${id}`, 'PUT', env),
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),
getEnvironmentsList: () => fetchApi('/environments'),
}; };
// [/DEF:api_module] // [/DEF:api:Data]
// [/DEF:api_module:Module]
// Export individual functions for easier use in components // Export individual functions for easier use in components
export const getPlugins = api.getPlugins; export const getPlugins = api.getPlugins;
@@ -128,3 +141,5 @@ export const addEnvironment = api.addEnvironment;
export const updateEnvironment = api.updateEnvironment; export const updateEnvironment = api.updateEnvironment;
export const deleteEnvironment = api.deleteEnvironment; export const deleteEnvironment = api.deleteEnvironment;
export const testEnvironmentConnection = api.testEnvironmentConnection; export const testEnvironmentConnection = api.testEnvironmentConnection;
export const updateEnvironmentSchedule = api.updateEnvironmentSchedule;
export const getEnvironmentsList = api.getEnvironmentsList;

View File

@@ -9,29 +9,37 @@ import { api } from './api.js';
// [DEF:plugins:Data] // [DEF:plugins:Data]
// @PURPOSE: Store for the list of available plugins. // @PURPOSE: Store for the list of available plugins.
export const plugins = writable([]); export const plugins = writable([]);
// [/DEF:plugins:Data]
// [DEF:tasks:Data] // [DEF:tasks:Data]
// @PURPOSE: Store for the list of tasks. // @PURPOSE: Store for the list of tasks.
export const tasks = writable([]); export const tasks = writable([]);
// [/DEF:tasks:Data]
// [DEF:selectedPlugin:Data] // [DEF:selectedPlugin:Data]
// @PURPOSE: Store for the currently selected plugin. // @PURPOSE: Store for the currently selected plugin.
export const selectedPlugin = writable(null); export const selectedPlugin = writable(null);
// [/DEF:selectedPlugin:Data]
// [DEF:selectedTask:Data] // [DEF:selectedTask:Data]
// @PURPOSE: Store for the currently selected task. // @PURPOSE: Store for the currently selected task.
export const selectedTask = writable(null); export const selectedTask = writable(null);
// [/DEF:selectedTask:Data]
// [DEF:currentPage:Data] // [DEF:currentPage:Data]
// @PURPOSE: Store for the current page. // @PURPOSE: Store for the current page.
export const currentPage = writable('dashboard'); export const currentPage = writable('dashboard');
// [/DEF:currentPage:Data]
// [DEF:taskLogs:Data] // [DEF:taskLogs:Data]
// @PURPOSE: Store for the logs of the currently selected task. // @PURPOSE: Store for the logs of the currently selected task.
export const taskLogs = writable([]); export const taskLogs = writable([]);
// [/DEF:taskLogs:Data]
// [DEF:fetchPlugins:Function] // [DEF:fetchPlugins:Function]
// @PURPOSE: Fetches plugins from the API and updates the plugins store. // @PURPOSE: Fetches plugins from the API and updates the plugins store.
// @PRE: None.
// @POST: plugins store is updated with data from the API.
export async function fetchPlugins() { export async function fetchPlugins() {
try { try {
console.log("[stores.fetchPlugins][Action] Fetching plugins."); console.log("[stores.fetchPlugins][Action] Fetching plugins.");
@@ -42,10 +50,12 @@ export async function fetchPlugins() {
console.error(`[stores.fetchPlugins][Coherence:Failed] Error fetching plugins context={{'error': '${error}'}}`); console.error(`[stores.fetchPlugins][Coherence:Failed] Error fetching plugins context={{'error': '${error}'}}`);
} }
} }
// [/DEF:fetchPlugins] // [/DEF:fetchPlugins:Function]
// [DEF:fetchTasks:Function] // [DEF:fetchTasks:Function]
// @PURPOSE: Fetches tasks from the API and updates the tasks store. // @PURPOSE: Fetches tasks from the API and updates the tasks store.
// @PRE: None.
// @POST: tasks store is updated with data from the API.
export async function fetchTasks() { export async function fetchTasks() {
try { try {
console.log("[stores.fetchTasks][Action] Fetching tasks."); console.log("[stores.fetchTasks][Action] Fetching tasks.");
@@ -56,5 +66,5 @@ export async function fetchTasks() {
console.error(`[stores.fetchTasks][Coherence:Failed] Error fetching tasks context={{'error': '${error}'}}`); console.error(`[stores.fetchTasks][Coherence:Failed] Error fetching tasks context={{'error': '${error}'}}`);
} }
} }
// [/DEF:fetchTasks] // [/DEF:fetchTasks:Function]
// [/DEF:stores_module] // [/DEF:stores_module:Module]

View File

@@ -8,9 +8,12 @@ import { writable } from 'svelte/store';
// [DEF:toasts:Data] // [DEF:toasts:Data]
// @PURPOSE: Writable store containing the list of active toasts. // @PURPOSE: Writable store containing the list of active toasts.
export const toasts = writable([]); export const toasts = writable([]);
// [/DEF:toasts:Data]
// [DEF:addToast:Function] // [DEF:addToast:Function]
// @PURPOSE: Adds a new toast message. // @PURPOSE: Adds a new toast message.
// @PRE: message string is provided.
// @POST: New toast is added to the store and scheduled for removal.
// @PARAM: message (string) - The message text. // @PARAM: message (string) - The message text.
// @PARAM: type (string) - The type of toast (info, success, error). // @PARAM: type (string) - The type of toast (info, success, error).
// @PARAM: duration (number) - Duration in ms before the toast is removed. // @PARAM: duration (number) - Duration in ms before the toast is removed.
@@ -20,14 +23,16 @@ export function addToast(message, type = 'info', duration = 3000) {
toasts.update(all => [...all, { id, message, type }]); toasts.update(all => [...all, { id, message, type }]);
setTimeout(() => removeToast(id), duration); setTimeout(() => removeToast(id), duration);
} }
// [/DEF:addToast] // [/DEF:addToast:Function]
// [DEF:removeToast:Function] // [DEF:removeToast:Function]
// @PURPOSE: Removes a toast message by ID. // @PURPOSE: Removes a toast message by ID.
// @PRE: id is provided.
// @POST: Toast is removed from the store.
// @PARAM: id (string) - The ID of the toast to remove. // @PARAM: id (string) - The ID of the toast to remove.
function removeToast(id) { function removeToast(id) {
console.log(`[toasts.removeToast][Action] Removing toast context={{'id': '${id}'}}`); console.log(`[toasts.removeToast][Action] Removing toast context={{'id': '${id}'}}`);
toasts.update(all => all.filter(t => t.id !== id)); toasts.update(all => all.filter(t => t.id !== id));
} }
// [/DEF:removeToast] // [/DEF:removeToast:Function]
// [/DEF:toasts_module] // [/DEF:toasts_module:Module]

View File

@@ -1,17 +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: {}
})
export default app
// [/DEF:main]

View File

@@ -17,23 +17,27 @@
// [DEF:onMount:Function] // [DEF:onMount:Function]
/** /**
* @purpose Fetch plugins when the component mounts. * @purpose Fetch plugins when the component mounts.
* @pre Component is mounting.
* @post plugins store is populated with available tools.
*/ */
onMount(async () => { onMount(async () => {
console.log("[Dashboard][Entry] Component mounted, fetching plugins."); console.log("[Dashboard][Entry] Component mounted, fetching plugins.");
await fetchPlugins(); await fetchPlugins();
}); });
// [/DEF:onMount] // [/DEF:onMount:Function]
// [DEF:selectPlugin:Function] // [DEF:selectPlugin:Function]
/** /**
* @purpose Selects a plugin to display its form. * @purpose Selects a plugin to display its form.
* @pre plugin object is provided.
* @post selectedPlugin store is updated.
* @param {Object} plugin - The plugin object to select. * @param {Object} plugin - The plugin object to select.
*/ */
function selectPlugin(plugin) { function selectPlugin(plugin) {
console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`); console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`);
selectedPlugin.set(plugin); selectedPlugin.set(plugin);
} }
// [/DEF:selectPlugin] // [/DEF:selectPlugin:Function]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
@@ -57,4 +61,4 @@
</div> </div>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<!-- [/DEF:Dashboard] --> <!-- [/DEF:Dashboard:Component] -->

View File

@@ -13,7 +13,7 @@
<script> <script>
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getSettings, updateGlobalSettings, getEnvironments, addEnvironment, updateEnvironment, deleteEnvironment, testEnvironmentConnection } from '../lib/api'; import { getSettings, updateGlobalSettings, getEnvironments, addEnvironment, updateEnvironment, deleteEnvironment, testEnvironmentConnection, updateEnvironmentSchedule } from '../lib/api';
import { addToast } from '../lib/toasts'; import { addToast } from '../lib/toasts';
// [/SECTION] // [/SECTION]
@@ -38,7 +38,11 @@
url: '', url: '',
username: '', username: '',
password: '', password: '',
is_default: false is_default: false,
backup_schedule: {
enabled: false,
cron_expression: '0 0 * * *'
}
}; };
let editingEnvId = null; let editingEnvId = null;
@@ -46,6 +50,8 @@
// [DEF:loadSettings:Function] // [DEF:loadSettings:Function]
/** /**
* @purpose Loads settings from the backend. * @purpose Loads settings from the backend.
* @pre Component mounted or refresh requested.
* @post settings object is populated with backend data.
*/ */
async function loadSettings() { async function loadSettings() {
try { try {
@@ -58,11 +64,13 @@
addToast('Failed to load settings', 'error'); addToast('Failed to load settings', 'error');
} }
} }
// [/DEF:loadSettings] // [/DEF:loadSettings:Function]
// [DEF:handleSaveGlobal:Function] // [DEF:handleSaveGlobal:Function]
/** /**
* @purpose Saves global settings to the backend. * @purpose Saves global settings to the backend.
* @pre settings.settings contains valid configuration.
* @post Backend global settings are updated.
*/ */
async function handleSaveGlobal() { async function handleSaveGlobal() {
try { try {
@@ -75,11 +83,13 @@
addToast('Failed to save global settings', 'error'); addToast('Failed to save global settings', 'error');
} }
} }
// [/DEF:handleSaveGlobal] // [/DEF:handleSaveGlobal:Function]
// [DEF:handleAddOrUpdateEnv:Function] // [DEF:handleAddOrUpdateEnv:Function]
/** /**
* @purpose Adds or updates an environment. * @purpose Adds or updates an environment.
* @pre newEnv contains valid environment details.
* @post Environment list is updated on backend and reloaded locally.
*/ */
async function handleAddOrUpdateEnv() { async function handleAddOrUpdateEnv() {
try { try {
@@ -99,11 +109,13 @@
addToast('Failed to save environment', 'error'); addToast('Failed to save environment', 'error');
} }
} }
// [/DEF:handleAddOrUpdateEnv] // [/DEF:handleAddOrUpdateEnv:Function]
// [DEF:handleDeleteEnv:Function] // [DEF:handleDeleteEnv:Function]
/** /**
* @purpose Deletes an environment. * @purpose Deletes an environment.
* @pre id of environment to delete is provided.
* @post Environment is removed from backend and list is reloaded.
* @param {string} id - The ID of the environment to delete. * @param {string} id - The ID of the environment to delete.
*/ */
async function handleDeleteEnv(id) { async function handleDeleteEnv(id) {
@@ -120,11 +132,13 @@
} }
} }
} }
// [/DEF:handleDeleteEnv] // [/DEF:handleDeleteEnv:Function]
// [DEF:handleTestEnv:Function] // [DEF:handleTestEnv:Function]
/** /**
* @purpose Tests the connection to an environment. * @purpose Tests the connection to an environment.
* @pre Environment ID is valid.
* @post Connection test result is displayed via toast.
* @param {string} id - The ID of the environment to test. * @param {string} id - The ID of the environment to test.
*/ */
async function handleTestEnv(id) { async function handleTestEnv(id) {
@@ -143,22 +157,26 @@
addToast('Failed to test connection', 'error'); addToast('Failed to test connection', 'error');
} }
} }
// [/DEF:handleTestEnv] // [/DEF:handleTestEnv:Function]
// [DEF:editEnv:Function] // [DEF:editEnv:Function]
/** /**
* @purpose Sets the form to edit an existing environment. * @purpose Sets the form to edit an existing environment.
* @pre env object is provided.
* @post newEnv is populated with env data and editingEnvId is set.
* @param {Object} env - The environment object to edit. * @param {Object} env - The environment object to edit.
*/ */
function editEnv(env) { function editEnv(env) {
newEnv = { ...env }; newEnv = { ...env };
editingEnvId = env.id; editingEnvId = env.id;
} }
// [/DEF:editEnv] // [/DEF:editEnv:Function]
// [DEF:resetEnvForm:Function] // [DEF:resetEnvForm:Function]
/** /**
* @purpose Resets the environment form. * @purpose Resets the environment form.
* @pre None.
* @post newEnv is reset to initial state and editingEnvId is cleared.
*/ */
function resetEnvForm() { function resetEnvForm() {
newEnv = { newEnv = {
@@ -167,11 +185,15 @@
url: '', url: '',
username: '', username: '',
password: '', password: '',
is_default: false is_default: false,
backup_schedule: {
enabled: false,
cron_expression: '0 0 * * *'
}
}; };
editingEnvId = null; editingEnvId = null;
} }
// [/DEF:resetEnvForm] // [/DEF:resetEnvForm:Function]
onMount(loadSettings); onMount(loadSettings);
</script> </script>
@@ -293,7 +315,21 @@
<label for="env_default" class="ml-2 block text-sm text-gray-900">Default Environment</label> <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">
<h3 class="text-lg font-medium mb-4 mt-6">Backup Schedule</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center">
<input type="checkbox" id="backup_enabled" bind:checked={newEnv.backup_schedule.enabled} class="h-4 w-4 text-blue-600 border-gray-300 rounded" />
<label for="backup_enabled" class="ml-2 block text-sm text-gray-900">Enable Automatic Backups</label>
</div>
<div>
<label for="cron_expression" class="block text-sm font-medium text-gray-700">Cron Expression</label>
<input type="text" id="cron_expression" bind:value={newEnv.backup_schedule.cron_expression} placeholder="0 0 * * *" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
<p class="text-xs text-gray-500 mt-1">Example: 0 0 * * * (daily at midnight), */5 * * * * (every 5 minutes)</p>
</div>
</div>
<div class="mt-6 flex gap-2">
<button on:click={handleAddOrUpdateEnv} class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"> <button on:click={handleAddOrUpdateEnv} class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
{editingEnvId ? 'Update' : 'Add'} Environment {editingEnvId ? 'Update' : 'Add'} Environment
</button> </button>
@@ -308,4 +344,4 @@
</div> </div>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<!-- [/DEF:Settings] --> <!-- [/DEF:Settings:Component] -->

View File

@@ -14,6 +14,11 @@
pluginsStore.set(data.plugins); pluginsStore.set(data.plugins);
} }
// [DEF:selectPlugin:Function]
/* @PURPOSE: Handles plugin selection and navigation.
@PRE: plugin object must be provided.
@POST: Navigates to migration or sets selectedPlugin store.
*/
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.id === 'superset-migration') {
@@ -22,7 +27,13 @@
selectedPlugin.set(plugin); selectedPlugin.set(plugin);
} }
} }
// [/DEF:selectPlugin:Function]
// [DEF:handleFormSubmit:Function]
/* @PURPOSE: Handles task creation from dynamic form submission.
@PRE: event.detail must contain task parameters.
@POST: Task is created via API and selectedTask store is updated.
*/
async function handleFormSubmit(event) { async function handleFormSubmit(event) {
console.log("[App.handleFormSubmit][Action] Handling form submission for task creation."); console.log("[App.handleFormSubmit][Action] Handling form submission for task creation.");
const params = event.detail; const params = event.detail;
@@ -36,6 +47,7 @@
console.error(`[App.handleFormSubmit][Coherence:Failed] Task creation failed error=${error}`); console.error(`[App.handleFormSubmit][Coherence:Failed] Task creation failed error=${error}`);
} }
} }
// [/DEF:handleFormSubmit:Function]
</script> </script>
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">

View File

@@ -1,5 +1,10 @@
import { api } from '../lib/api'; import { api } from '../lib/api';
// [DEF:load:Function]
/* @PURPOSE: Loads initial plugin data for the dashboard.
@PRE: None.
@POST: Returns an object with plugins or an error message.
*/
/** @type {import('./$types').PageLoad} */ /** @type {import('./$types').PageLoad} */
export async function load() { export async function load() {
try { try {
@@ -15,3 +20,4 @@ export async function load() {
}; };
} }
} }
// [/DEF:load:Function]

View File

@@ -51,6 +51,7 @@
// [DEF:fetchEnvironments:Function] // [DEF:fetchEnvironments:Function]
/** /**
* @purpose Fetches the list of environments from the API. * @purpose Fetches the list of environments from the API.
* @pre None.
* @post environments state is updated. * @post environments state is updated.
*/ */
async function fetchEnvironments() { async function fetchEnvironments() {
@@ -64,11 +65,12 @@
loading = false; loading = false;
} }
} }
// [/DEF:fetchEnvironments] // [/DEF:fetchEnvironments:Function]
// [DEF:fetchDashboards:Function] // [DEF:fetchDashboards:Function]
/** /**
* @purpose Fetches dashboards for the selected source environment. * @purpose Fetches dashboards for the selected source environment.
* @pre envId is a valid environment ID.
* @param envId The environment ID. * @param envId The environment ID.
* @post dashboards state is updated. * @post dashboards state is updated.
*/ */
@@ -83,7 +85,7 @@
dashboards = []; dashboards = [];
} }
} }
// [/DEF:fetchDashboards] // [/DEF:fetchDashboards:Function]
onMount(fetchEnvironments); onMount(fetchEnvironments);
@@ -93,6 +95,8 @@
// [DEF:fetchDatabases:Function] // [DEF:fetchDatabases:Function]
/** /**
* @purpose Fetches databases from both environments and gets suggestions. * @purpose Fetches databases from both environments and gets suggestions.
* @pre sourceEnvId and targetEnvId must be set.
* @post sourceDatabases, targetDatabases, mappings, and suggestions are updated.
*/ */
async function fetchDatabases() { async function fetchDatabases() {
if (!sourceEnvId || !targetEnvId) return; if (!sourceEnvId || !targetEnvId) return;
@@ -123,11 +127,13 @@
fetchingDbs = false; fetchingDbs = false;
} }
} }
// [/DEF:fetchDatabases] // [/DEF:fetchDatabases:Function]
// [DEF:handleMappingUpdate:Function] // [DEF:handleMappingUpdate:Function]
/** /**
* @purpose Saves a mapping to the backend. * @purpose Saves a mapping to the backend.
* @pre event.detail contains sourceUuid and targetUuid.
* @post Mapping is saved and local mappings list is updated.
*/ */
async function handleMappingUpdate(event: CustomEvent) { async function handleMappingUpdate(event: CustomEvent) {
const { sourceUuid, targetUuid } = event.detail; const { sourceUuid, targetUuid } = event.detail;
@@ -158,18 +164,24 @@
error = e.message; error = e.message;
} }
} }
// [/DEF:handleMappingUpdate] // [/DEF:handleMappingUpdate:Function]
// [DEF:handleViewLogs:Function] // [DEF:handleViewLogs:Function]
// @PURPOSE: Opens the log viewer for a specific task.
// @PRE: event.detail contains task object.
// @POST: logViewer state updated and showLogViewer set to true.
function handleViewLogs(event: CustomEvent) { function handleViewLogs(event: CustomEvent) {
const task = event.detail; const task = event.detail;
logViewerTaskId = task.id; logViewerTaskId = task.id;
logViewerTaskStatus = task.status; logViewerTaskStatus = task.status;
showLogViewer = true; showLogViewer = true;
} }
// [/DEF:handleViewLogs] // [/DEF:handleViewLogs:Function]
// [DEF:handlePasswordPrompt:Function] // [DEF:handlePasswordPrompt:Function]
// @PURPOSE: Reactive logic to show password prompt when a task is awaiting input.
// @PRE: selectedTask status is AWAITING_INPUT.
// @POST: showPasswordPrompt set to true with request data.
// This is triggered by TaskRunner or TaskHistory when a task needs input // This is triggered by TaskRunner or TaskHistory when a task needs input
// For now, we rely on the WebSocket or manual check. // For now, we rely on the WebSocket or manual check.
// Ideally, TaskHistory or TaskRunner emits an event when input is needed. // Ideally, TaskHistory or TaskRunner emits an event when input is needed.
@@ -188,7 +200,12 @@
// showPasswordPrompt = false; // showPasswordPrompt = false;
// Actually, don't auto-close, let the user or success handler close it. // Actually, don't auto-close, let the user or success handler close it.
} }
// [/DEF:handlePasswordPrompt:Function]
// [DEF:handleResumeMigration:Function]
// @PURPOSE: Resumes a migration task with provided passwords.
// @PRE: event.detail contains passwords.
// @POST: resumeTask is called and showPasswordPrompt is hidden on success.
async function handleResumeMigration(event: CustomEvent) { async function handleResumeMigration(event: CustomEvent) {
if (!$selectedTask) return; if (!$selectedTask) return;
@@ -203,11 +220,13 @@
// Keep prompt open // Keep prompt open
} }
} }
// [/DEF:handleResumeMigration:Function]
// [DEF:startMigration:Function] // [DEF:startMigration:Function]
/** /**
* @purpose Starts the migration process. * @purpose Starts the migration process.
* @pre sourceEnvId and targetEnvId must be set and different. * @pre sourceEnvId and targetEnvId must be set and different.
* @post Migration task is started and selectedTask is updated.
*/ */
async function startMigration() { async function startMigration() {
if (!sourceEnvId || !targetEnvId) { if (!sourceEnvId || !targetEnvId) {
@@ -270,7 +289,7 @@
error = e.message; error = e.message;
} }
} }
// [/DEF:startMigration] // [/DEF:startMigration:Function]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
@@ -396,4 +415,4 @@
/* Page specific styles */ /* Page specific styles */
</style> </style>
<!-- [/DEF:MigrationDashboard] --> <!-- [/DEF:MigrationDashboard:Component] -->

View File

@@ -30,6 +30,10 @@
let success = ""; let success = "";
// [/SECTION] // [/SECTION]
// [DEF:fetchEnvironments:Function]
// @PURPOSE: Fetches the list of environments.
// @PRE: None.
// @POST: environments array is populated.
async function fetchEnvironments() { async function fetchEnvironments() {
try { try {
const response = await fetch('/api/environments'); const response = await fetch('/api/environments');
@@ -41,12 +45,15 @@
loading = false; loading = false;
} }
} }
// [/DEF:fetchEnvironments:Function]
onMount(fetchEnvironments); onMount(fetchEnvironments);
// [DEF:fetchDatabases:Function] // [DEF:fetchDatabases:Function]
/** /**
* @purpose Fetches databases from both environments and gets suggestions. * @purpose Fetches databases from both environments and gets suggestions.
* @pre sourceEnvId and targetEnvId must be set.
* @post sourceDatabases, targetDatabases, mappings, and suggestions are updated.
*/ */
async function fetchDatabases() { async function fetchDatabases() {
if (!sourceEnvId || !targetEnvId) return; if (!sourceEnvId || !targetEnvId) return;
@@ -78,11 +85,13 @@
fetchingDbs = false; fetchingDbs = false;
} }
} }
// [/DEF:fetchDatabases] // [/DEF:fetchDatabases:Function]
// [DEF:handleUpdate:Function] // [DEF:handleUpdate:Function]
/** /**
* @purpose Saves a mapping to the backend. * @purpose Saves a mapping to the backend.
* @pre event.detail contains sourceUuid and targetUuid.
* @post Mapping is saved and local mappings list is updated.
*/ */
async function handleUpdate(event: CustomEvent) { async function handleUpdate(event: CustomEvent) {
const { sourceUuid, targetUuid } = event.detail; const { sourceUuid, targetUuid } = event.detail;
@@ -114,7 +123,7 @@
error = e.message; error = e.message;
} }
} }
// [/DEF:handleUpdate] // [/DEF:handleUpdate:Function]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
@@ -180,4 +189,4 @@
/* Page specific styles */ /* Page specific styles */
</style> </style>
<!-- [/DEF:MappingManagement] --> <!-- [/DEF:MappingManagement:Component] -->

View File

@@ -21,6 +21,11 @@
let editingEnvId = null; let editingEnvId = null;
// [DEF:handleSaveGlobal:Function]
/* @PURPOSE: Saves global application settings.
@PRE: settings.settings must contain valid configuration.
@POST: Global settings are updated via API.
*/
async function handleSaveGlobal() { async function handleSaveGlobal() {
try { try {
console.log("[Settings.handleSaveGlobal][Action] Saving global settings."); console.log("[Settings.handleSaveGlobal][Action] Saving global settings.");
@@ -32,7 +37,13 @@
addToast('Failed to save global settings', 'error'); addToast('Failed to save global settings', 'error');
} }
} }
// [/DEF:handleSaveGlobal:Function]
// [DEF:handleAddOrUpdateEnv:Function]
/* @PURPOSE: Adds a new environment or updates an existing one.
@PRE: newEnv must contain valid environment details.
@POST: Environment is saved and page is reloaded to reflect changes.
*/
async function handleAddOrUpdateEnv() { async function handleAddOrUpdateEnv() {
try { try {
console.log(`[Settings.handleAddOrUpdateEnv][Action] ${editingEnvId ? 'Updating' : 'Adding'} environment.`); console.log(`[Settings.handleAddOrUpdateEnv][Action] ${editingEnvId ? 'Updating' : 'Adding'} environment.`);
@@ -54,7 +65,13 @@
addToast('Failed to save environment', 'error'); addToast('Failed to save environment', 'error');
} }
} }
// [/DEF:handleAddOrUpdateEnv:Function]
// [DEF:handleDeleteEnv:Function]
/* @PURPOSE: Deletes a Superset environment.
@PRE: id must be a valid environment ID.
@POST: Environment is removed and page is reloaded.
*/
async function handleDeleteEnv(id) { async function handleDeleteEnv(id) {
if (confirm('Are you sure you want to delete this environment?')) { if (confirm('Are you sure you want to delete this environment?')) {
try { try {
@@ -69,7 +86,13 @@
} }
} }
} }
// [/DEF:handleDeleteEnv:Function]
// [DEF:handleTestEnv:Function]
/* @PURPOSE: Tests the connection to a Superset environment.
@PRE: id must be a valid environment ID.
@POST: Displays success or error toast based on connection result.
*/
async function handleTestEnv(id) { async function handleTestEnv(id) {
try { try {
console.log(`[Settings.handleTestEnv][Action] Testing environment: ${id}`); console.log(`[Settings.handleTestEnv][Action] Testing environment: ${id}`);
@@ -86,12 +109,24 @@
addToast('Failed to test connection', 'error'); addToast('Failed to test connection', 'error');
} }
} }
// [/DEF:handleTestEnv:Function]
// [DEF:editEnv:Function]
/* @PURPOSE: Populates the environment form for editing.
@PRE: env object must be provided.
@POST: newEnv and editingEnvId are updated.
*/
function editEnv(env) { function editEnv(env) {
newEnv = { ...env }; newEnv = { ...env };
editingEnvId = env.id; editingEnvId = env.id;
} }
// [/DEF:editEnv:Function]
// [DEF:resetEnvForm:Function]
/* @PURPOSE: Resets the environment creation/edit form to default state.
@PRE: None.
@POST: newEnv is cleared and editingEnvId is set to null.
*/
function resetEnvForm() { function resetEnvForm() {
newEnv = { newEnv = {
id: '', id: '',
@@ -103,6 +138,7 @@
}; };
editingEnvId = null; editingEnvId = null;
} }
// [/DEF:resetEnvForm:Function]
</script> </script>
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">

View File

@@ -1,5 +1,10 @@
import { api } from '../../lib/api'; import { api } from '../../lib/api';
// [DEF:load:Function]
/* @PURPOSE: Loads application settings and environment list.
@PRE: API must be reachable.
@POST: Returns settings object or default values on error.
*/
/** @type {import('./$types').PageLoad} */ /** @type {import('./$types').PageLoad} */
export async function load() { export async function load() {
try { try {
@@ -21,3 +26,4 @@ export async function load() {
}; };
} }
} }
// [/DEF:load:Function]

View File

@@ -0,0 +1,40 @@
<!-- [DEF:ConnectionsSettingsPage:Component] -->
<!--
@SEMANTICS: settings, connections, page
@PURPOSE: Page for managing database connection configurations.
@LAYER: UI
-->
<script>
import ConnectionForm from '../../../components/tools/ConnectionForm.svelte';
import ConnectionList from '../../../components/tools/ConnectionList.svelte';
let listComponent;
// [DEF:handleSuccess:Function]
/* @PURPOSE: Refreshes the connection list after a successful creation.
@PRE: listComponent must be bound.
@POST: Triggers the fetchConnections method on the list component.
*/
function handleSuccess() {
if (listComponent) {
listComponent.fetchConnections();
}
}
// [/DEF:handleSuccess:Function]
</script>
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Connection Management</h1>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<ConnectionForm on:success={handleSuccess} />
</div>
<div>
<ConnectionList bind:this={listComponent} />
</div>
</div>
</div>
</div>
<!-- [/DEF:ConnectionsSettingsPage:Component] -->

View File

@@ -0,0 +1,187 @@
<!-- [DEF:TaskManagementPage:Component] -->
<!--
@SEMANTICS: tasks, management, history, logs
@PURPOSE: Page for managing and monitoring tasks.
@LAYER: Page
@RELATION: USES -> TaskList
@RELATION: USES -> TaskLogViewer
-->
<script>
import { onMount, onDestroy } from 'svelte';
import { getTasks, createTask, getEnvironmentsList } from '../../lib/api';
import { addToast } from '../../lib/toasts';
import TaskList from '../../components/TaskList.svelte';
import TaskLogViewer from '../../components/TaskLogViewer.svelte';
let tasks = [];
let environments = [];
let loading = true;
let selectedTaskId = null;
let pollInterval;
let showBackupModal = false;
let selectedEnvId = '';
// [DEF:loadInitialData:Function]
/**
* @purpose Loads tasks and environments on page initialization.
* @pre API must be reachable.
* @post tasks and environments variables are populated.
*/
async function loadInitialData() {
console.log("[loadInitialData][Action] Loading initial tasks and environments");
try {
loading = true;
const [tasksData, envsData] = await Promise.all([
getTasks(),
getEnvironmentsList()
]);
tasks = tasksData;
environments = envsData;
console.log(`[loadInitialData][Coherence:OK] Data loaded context={{'tasks': ${tasks.length}, 'envs': ${environments.length}}}`);
} catch (error) {
console.error(`[loadInitialData][Coherence:Failed] Failed to load tasks data context={{'error': '${error.message}'}}`);
} finally {
loading = false;
}
}
// [/DEF:loadInitialData:Function]
// [DEF:refreshTasks:Function]
/**
* @purpose Periodically refreshes the task list.
* @pre API must be reachable.
* @post tasks variable is updated if data is valid.
*/
async function refreshTasks() {
try {
const data = await getTasks();
// Ensure we don't try to parse HTML as JSON if the route returns 404
if (Array.isArray(data)) {
tasks = data;
}
} catch (error) {
console.error(`[refreshTasks][Coherence:Failed] Failed to refresh tasks context={{'error': '${error.message}'}}`);
}
}
// [/DEF:refreshTasks:Function]
// [DEF:handleSelectTask:Function]
/**
* @purpose Updates the selected task ID when a task is clicked.
* @pre event.detail.id must be provided.
* @post selectedTaskId is updated.
*/
function handleSelectTask(event) {
selectedTaskId = event.detail.id;
console.log(`[handleSelectTask][Action] Task selected context={{'taskId': '${selectedTaskId}'}}`);
}
// [/DEF:handleSelectTask:Function]
// [DEF:handleRunBackup:Function]
/**
* @purpose Triggers a manual backup task for the selected environment.
* @pre selectedEnvId must not be empty.
* @post Backup task is created and task list is refreshed.
*/
async function handleRunBackup() {
if (!selectedEnvId) {
addToast('Please select an environment', 'error');
return;
}
console.log(`[handleRunBackup][Action] Starting backup for env context={{'envId': '${selectedEnvId}'}}`);
try {
const task = await createTask('superset-backup', { environment_id: selectedEnvId });
addToast('Backup task started', 'success');
showBackupModal = false;
selectedTaskId = task.id;
await refreshTasks();
console.log(`[handleRunBackup][Coherence:OK] Backup task created context={{'taskId': '${task.id}'}}`);
} catch (error) {
console.error(`[handleRunBackup][Coherence:Failed] Failed to start backup context={{'error': '${error.message}'}}`);
}
}
// [/DEF:handleRunBackup:Function]
onMount(() => {
loadInitialData();
pollInterval = setInterval(refreshTasks, 3000);
});
onDestroy(() => {
if (pollInterval) clearInterval(pollInterval);
});
</script>
<div class="container mx-auto p-4 max-w-6xl">
<div class="flex justify-between items-center mb-6">
<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="lg:col-span-1">
<h2 class="text-lg font-semibold mb-3 text-gray-700">Recent Tasks</h2>
<TaskList {tasks} {loading} on:select={handleSelectTask} />
</div>
<div class="lg:col-span-2">
<h2 class="text-lg font-semibold mb-3 text-gray-700">Task Details & Logs</h2>
{#if selectedTaskId}
<div class="bg-white rounded-lg shadow-lg h-[600px] flex flex-col">
<TaskLogViewer
taskId={selectedTaskId}
taskStatus={tasks.find(t => t.id === selectedTaskId)?.status}
inline={true}
/>
</div>
{: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">
<p>Select a task to view logs and details</p>
</div>
{/if}
</div>
</div>
</div>
{#if showBackupModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-xl font-bold mb-4">Run Manual Backup</h3>
<div class="mb-4">
<label for="env-select" class="block text-sm font-medium text-gray-700 mb-1">Target Environment</label>
<select
id="env-select"
bind:value={selectedEnvId}
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2 border"
>
<option value="" disabled>-- Select Environment --</option>
{#each environments as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<div class="flex justify-end space-x-3">
<button
on:click={() => showBackupModal = false}
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition"
>
Cancel
</button>
<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>
{/if}
<!-- [/DEF:TaskManagementPage:Component] -->

View File

@@ -0,0 +1,26 @@
<!-- [DEF:DebugPage:Component] -->
<!--
@SEMANTICS: debug, page, tool
@PURPOSE: Page for system diagnostics and debugging.
@LAYER: UI
-->
<script>
import DebugTool from '../../../components/tools/DebugTool.svelte';
import TaskRunner from '../../../components/TaskRunner.svelte';
</script>
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<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="lg:col-span-2">
<DebugTool />
</div>
<div class="lg:col-span-1">
<TaskRunner />
</div>
</div>
</div>
</div>
<!-- [/DEF:DebugPage:Component] -->

View File

@@ -0,0 +1,26 @@
<!-- [DEF:MapperPage:Component] -->
<!--
@SEMANTICS: mapper, page, tool
@PURPOSE: Page for the dataset column mapper tool.
@LAYER: UI
-->
<script>
import MapperTool from '../../../components/tools/MapperTool.svelte';
import TaskRunner from '../../../components/TaskRunner.svelte';
</script>
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<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="lg:col-span-2">
<MapperTool />
</div>
<div class="lg:col-span-1">
<TaskRunner />
</div>
</div>
</div>
</div>
<!-- [/DEF:MapperPage:Component] -->

View File

@@ -0,0 +1,26 @@
<!-- [DEF:SearchPage:Component] -->
<!--
@SEMANTICS: search, page, tool
@PURPOSE: Page for the dataset search tool.
@LAYER: UI
-->
<script>
import SearchTool from '../../../components/tools/SearchTool.svelte';
import TaskRunner from '../../../components/TaskRunner.svelte';
</script>
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Dataset Search</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<SearchTool />
</div>
<div class="lg:col-span-1">
<TaskRunner />
</div>
</div>
</div>
</div>
<!-- [/DEF:SearchPage:Component] -->

View File

@@ -0,0 +1,70 @@
/**
* Service for interacting with the Connection Management API.
*/
const API_BASE = '/api/settings/connections';
// [DEF:getConnections:Function]
/* @PURPOSE: Fetch a list of saved connections.
@PRE: None.
@POST: Returns a promise resolving to an array of connections.
*/
/**
* Fetch a list of saved connections.
* @returns {Promise<Array>} List of connections.
*/
export async function getConnections() {
const response = await fetch(API_BASE);
if (!response.ok) {
throw new Error(`Failed to fetch connections: ${response.statusText}`);
}
return await response.json();
}
// [/DEF:getConnections:Function]
// [DEF:createConnection:Function]
/* @PURPOSE: Create a new connection configuration.
@PRE: connectionData must be a valid object.
@POST: Returns a promise resolving to the created connection.
*/
/**
* Create a new connection configuration.
* @param {Object} connectionData - The connection data.
* @returns {Promise<Object>} The created connection instance.
*/
export async function createConnection(connectionData) {
const response = await fetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(connectionData)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to create connection: ${response.statusText}`);
}
return await response.json();
}
// [/DEF:createConnection:Function]
// [DEF:deleteConnection:Function]
/* @PURPOSE: Delete a connection configuration.
@PRE: connectionId must be a valid string.
@POST: Returns a promise that resolves when deletion is complete.
*/
/**
* Delete a connection configuration.
* @param {string} connectionId - The ID of the connection to delete.
*/
export async function deleteConnection(connectionId) {
const response = await fetch(`${API_BASE}/${connectionId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to delete connection: ${response.statusText}`);
}
}
// [/DEF:deleteConnection:Function]

View File

@@ -4,6 +4,11 @@
const API_BASE = '/api/tasks'; const API_BASE = '/api/tasks';
// [DEF:getTasks:Function]
/* @PURPOSE: Fetch a list of tasks with pagination and optional status filter.
@PRE: limit and offset are numbers.
@POST: Returns a promise resolving to a list of tasks.
*/
/** /**
* Fetch a list of tasks with pagination and optional status filter. * Fetch a list of tasks with pagination and optional status filter.
* @param {number} limit - Maximum number of tasks to return. * @param {number} limit - Maximum number of tasks to return.
@@ -26,7 +31,13 @@ export async function getTasks(limit = 10, offset = 0, status = null) {
} }
return await response.json(); return await response.json();
} }
// [/DEF:getTasks:Function]
// [DEF:getTask:Function]
/* @PURPOSE: Fetch details for a specific task.
@PRE: taskId must be provided.
@POST: Returns a promise resolving to task details.
*/
/** /**
* Fetch details for a specific task. * Fetch details for a specific task.
* @param {string} taskId - The ID of the task. * @param {string} taskId - The ID of the task.
@@ -39,7 +50,13 @@ export async function getTask(taskId) {
} }
return await response.json(); return await response.json();
} }
// [/DEF:getTask:Function]
// [DEF:getTaskLogs:Function]
/* @PURPOSE: Fetch logs for a specific task.
@PRE: taskId must be provided.
@POST: Returns a promise resolving to a list of log entries.
*/
/** /**
* Fetch logs for a specific task. * Fetch logs for a specific task.
* @param {string} taskId - The ID of the task. * @param {string} taskId - The ID of the task.
@@ -55,7 +72,13 @@ export async function getTaskLogs(taskId) {
const task = await getTask(taskId); const task = await getTask(taskId);
return task.logs || []; return task.logs || [];
} }
// [/DEF:getTaskLogs:Function]
// [DEF:resumeTask:Function]
/* @PURPOSE: Resume a task that is awaiting input (e.g., passwords).
@PRE: taskId and passwords must be provided.
@POST: Returns a promise resolving to the updated task object.
*/
/** /**
* Resume a task that is awaiting input (e.g., passwords). * Resume a task that is awaiting input (e.g., passwords).
* @param {string} taskId - The ID of the task. * @param {string} taskId - The ID of the task.
@@ -77,7 +100,13 @@ export async function resumeTask(taskId, passwords) {
} }
return await response.json(); return await response.json();
} }
// [/DEF:resumeTask:Function]
// [DEF:resolveTask:Function]
/* @PURPOSE: Resolve a task that is awaiting mapping.
@PRE: taskId and resolutionParams must be provided.
@POST: Returns a promise resolving to the updated task object.
*/
/** /**
* Resolve a task that is awaiting mapping. * Resolve a task that is awaiting mapping.
* @param {string} taskId - The ID of the task. * @param {string} taskId - The ID of the task.
@@ -99,7 +128,13 @@ export async function resolveTask(taskId, resolutionParams) {
} }
return await response.json(); return await response.json();
} }
// [/DEF:resolveTask:Function]
// [DEF:clearTasks:Function]
/* @PURPOSE: Clear tasks based on status.
@PRE: status is a string or null.
@POST: Returns a promise that resolves when tasks are cleared.
*/
/** /**
* Clear tasks based on status. * Clear tasks based on status.
* @param {string|null} status - Filter by task status (optional). * @param {string|null} status - Filter by task status (optional).
@@ -117,4 +152,5 @@ export async function clearTasks(status = null) {
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to clear tasks: ${response.statusText}`); throw new Error(`Failed to clear tasks: ${response.statusText}`);
} }
} }
// [/DEF:clearTasks:Function]

View File

@@ -0,0 +1,52 @@
/**
* Service for generic Task API communication used by Tools.
*/
const API_BASE = '/api/tasks';
// [DEF:runTask:Function]
/* @PURPOSE: Start a new task for a given plugin.
@PRE: pluginId and params must be provided.
@POST: Returns a promise resolving to the task instance.
*/
/**
* Start a new task for a given plugin.
* @param {string} pluginId - The ID of the plugin to run.
* @param {Object} params - Parameters for the plugin.
* @returns {Promise<Object>} The created task instance.
*/
export async function runTask(pluginId, params) {
const response = await fetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ plugin_id: pluginId, params })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to start task: ${response.statusText}`);
}
return await response.json();
}
// [/DEF:runTask:Function]
// [DEF:getTaskStatus:Function]
/* @PURPOSE: Fetch details for a specific task (to poll status or get result).
@PRE: taskId must be provided.
@POST: Returns a promise resolving to task details.
*/
/**
* Fetch details for a specific task (to poll status or get result).
* @param {string} taskId - The ID of the task.
* @returns {Promise<Object>} Task details.
*/
export async function getTaskStatus(taskId) {
const response = await fetch(`${API_BASE}/${taskId}`);
if (!response.ok) {
throw new Error(`Failed to fetch task ${taskId}: ${response.statusText}`);
}
return await response.json();
}
// [/DEF:getTaskStatus:Function]

613
generate_semantic_map.py Normal file
View File

@@ -0,0 +1,613 @@
# [DEF:generate_semantic_map:Module]
#
# @SEMANTICS: semantic_analysis, parser, map_generator, compliance_checker
# @PURPOSE: Scans the codebase to generate a Semantic Map and Compliance Report based on the System Standard.
# @LAYER: DevOps/Tooling
# @RELATION: READS -> FileSystem
# @RELATION: PRODUCES -> semantics/semantic_map.json
# @RELATION: PRODUCES -> specs/project_map.md
# @RELATION: PRODUCES -> semantics/reports/semantic_report_*.md
# [SECTION: IMPORTS]
import os
import re
import json
import datetime
import fnmatch
from typing import Dict, List, Optional, Any, Pattern, Tuple, Set
# Mock belief_scope for the script itself to avoid import issues
class belief_scope:
# [DEF:__init__:Function]
# @PURPOSE: Mock init.
# @PRE: name is a string.
# @POST: Instance initialized.
def __init__(self, name):
self.name = name
# [/DEF:__init__:Function]
# [DEF:__enter__:Function]
# @PURPOSE: Mock enter.
# @PRE: Instance initialized.
# @POST: Returns self.
def __enter__(self):
return self
# [/DEF:__enter__:Function]
# [DEF:__exit__:Function]
# @PURPOSE: Mock exit.
# @PRE: Context entered.
# @POST: Context exited.
def __exit__(self, *args):
pass
# [/DEF:__exit__:Function]
# [/SECTION]
# [SECTION: CONFIGURATION]
PROJECT_ROOT = "."
IGNORE_DIRS = {
".git", "__pycache__", "node_modules", "venv", ".pytest_cache",
".kilocode", "backups", "logs", "semantics", "specs"
}
IGNORE_FILES = {
"package-lock.json", "poetry.lock", "yarn.lock"
}
OUTPUT_JSON = "semantics/semantic_map.json"
OUTPUT_COMPRESSED_MD = "specs/project_map.md"
REPORTS_DIR = "semantics/reports"
MANDATORY_TAGS = {
"Module": ["PURPOSE", "LAYER", "SEMANTICS"],
"Component": ["PURPOSE", "LAYER", "SEMANTICS"],
"Function": ["PURPOSE", "PRE", "POST"],
"Class": ["PURPOSE"]
}
# [/SECTION]
# [DEF:SemanticEntity:Class]
# @PURPOSE: Represents a code entity (Module, Function, Component) found during parsing.
# @INVARIANT: start_line is always set; end_line is set upon closure.
class SemanticEntity:
# [DEF:__init__:Function]
# @PURPOSE: Initializes a new SemanticEntity instance.
# @PRE: name, type_, start_line, file_path are provided.
# @POST: Instance is initialized with default values.
def __init__(self, name: str, type_: str, start_line: int, file_path: str):
with belief_scope("__init__"):
self.name = name
self.type = type_
self.start_line = start_line
self.end_line: Optional[int] = None
self.file_path = file_path
self.tags: Dict[str, str] = {}
self.relations: List[Dict[str, str]] = []
self.children: List['SemanticEntity'] = []
self.parent: Optional['SemanticEntity'] = None
self.compliance_issues: List[str] = []
# [/DEF:__init__:Function]
# [DEF:to_dict:Function]
# @PURPOSE: Serializes the entity to a dictionary for JSON output.
# @PRE: Entity is fully populated.
# @POST: Returns a dictionary representation.
# @RETURN: Dict representation of the entity.
def to_dict(self) -> Dict[str, Any]:
with belief_scope("to_dict"):
return {
"name": self.name,
"type": self.type,
"start_line": self.start_line,
"end_line": self.end_line,
"tags": self.tags,
"relations": self.relations,
"children": [c.to_dict() for c in self.children],
"compliance": {
"valid": len(self.compliance_issues) == 0,
"issues": self.compliance_issues
}
}
# [/DEF:to_dict:Function]
# [DEF:validate:Function]
# @PURPOSE: Checks for semantic compliance (closure, mandatory tags, belief state).
# @PRE: Entity structure is complete.
# @POST: Populates self.compliance_issues.
def validate(self):
with belief_scope("validate"):
# 1. Check Closure
if self.end_line is None:
self.compliance_issues.append(f"Unclosed Anchor: [DEF:{self.name}:{self.type}] started at line {self.start_line}")
# 2. Check Mandatory Tags
required = MANDATORY_TAGS.get(self.type, [])
for req_tag in required:
found = False
for existing_tag in self.tags:
if existing_tag.upper() == req_tag:
found = True
break
if not found:
self.compliance_issues.append(f"Missing Mandatory Tag: @{req_tag}")
# 3. Check for Belief State Logging (Python only)
if self.type == "Function" and self.file_path.endswith(".py"):
if not getattr(self, 'has_belief_scope', False):
self.compliance_issues.append("Missing Belief State Logging: Function should use belief_scope context manager.")
# Recursive validation
for child in self.children:
child.validate()
# [/DEF:validate:Function]
# [DEF:get_score:Function]
# @PURPOSE: Calculates a compliance score (0.0 to 1.0).
# @PRE: validate() has been called.
# @POST: Returns a float score.
# @RETURN: Float score.
def get_score(self) -> float:
with belief_scope("get_score"):
if self.end_line is None:
return 0.0
score = 1.0
required = MANDATORY_TAGS.get(self.type, [])
if required:
found_count = 0
for req_tag in required:
for existing_tag in self.tags:
if existing_tag.upper() == req_tag:
found_count += 1
break
if found_count < len(required):
# Penalty proportional to missing tags
score -= 0.5 * (1 - (found_count / len(required)))
return max(0.0, score)
# [/DEF:get_score:Function]
# [/DEF:SemanticEntity:Class]
# [DEF:get_patterns:Function]
# @PURPOSE: Returns regex patterns for a specific language.
# @PRE: lang is either 'python' or 'svelte_js'.
# @POST: Returns a dictionary of compiled regex patterns.
# @PARAM: lang (str) - 'python' or 'svelte_js'
# @RETURN: Dict containing compiled regex patterns.
def get_patterns(lang: str) -> Dict[str, Pattern]:
with belief_scope("get_patterns"):
if lang == "python":
return {
"anchor_start": re.compile(r"#\s*\[DEF:(?P<name>[\w\.]+):(?P<type>\w+)\]"),
"anchor_end": re.compile(r"#\s*\[/DEF:(?P<name>[\w\.]+):(?P<type>\w+)\]"),
"tag": re.compile(r"#\s*@(?P<tag>[A-Z_]+):\s*(?P<value>.*)"),
"relation": re.compile(r"#\s*@RELATION:\s*(?P<type>\w+)\s*->\s*(?P<target>.*)"),
"func_def": re.compile(r"^\s*(async\s+)?def\s+(?P<name>\w+)"),
"belief_scope": re.compile(r"with\s+(\w+\.)?belief_scope\("),
}
else:
return {
"html_anchor_start": re.compile(r"<!--\s*\[DEF:(?P<name>[\w\.]+):(?P<type>\w+)\]\s*-->"),
"html_anchor_end": re.compile(r"<!--\s*\[/DEF:(?P<name>[\w\.]+):(?P<type>\w+)\]\s*-->"),
"js_anchor_start": re.compile(r"//\s*\[DEF:(?P<name>[\w\.]+):(?P<type>\w+)\]"),
"js_anchor_end": re.compile(r"//\s*\[/DEF:(?P<name>[\w\.]+):(?P<type>\w+)\]"),
"html_tag": re.compile(r"@(?P<tag>[A-Z_]+):\s*(?P<value>.*)"),
"jsdoc_tag": re.compile(r"\*\s*@(?P<tag>[a-zA-Z]+)\s+(?P<value>.*)"),
"relation": re.compile(r"//\s*@RELATION:\s*(?P<type>\w+)\s*->\s*(?P<target>.*)"),
"func_def": re.compile(r"^\s*(export\s+)?(async\s+)?function\s+(?P<name>\w+)"),
}
# [/DEF:get_patterns:Function]
# [DEF:parse_file:Function]
# @PURPOSE: Parses a single file to extract semantic entities.
# @PRE: full_path, rel_path, lang are valid strings.
# @POST: Returns extracted entities and list of issues.
# @PARAM: full_path - Absolute path to file.
# @PARAM: rel_path - Relative path from project root.
# @PARAM: lang - Language identifier.
# @RETURN: Tuple[List[SemanticEntity], List[str]] - Entities found and global issues.
def parse_file(full_path: str, rel_path: str, lang: str) -> Tuple[List[SemanticEntity], List[str]]:
with belief_scope("parse_file"):
issues: List[str] = []
try:
with open(full_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
except Exception as e:
return [], [f"Could not read file {rel_path}: {e}"]
stack: List[SemanticEntity] = []
file_entities: List[SemanticEntity] = []
patterns = get_patterns(lang)
for i, line in enumerate(lines):
lineno = i + 1
line = line.strip()
# 1. Check for Anchor Start
match_start = None
if lang == "python":
match_start = patterns["anchor_start"].search(line)
else:
match_start = patterns["html_anchor_start"].search(line) or patterns["js_anchor_start"].search(line)
if match_start:
name = match_start.group("name")
type_ = match_start.group("type")
entity = SemanticEntity(name, type_, lineno, rel_path)
if stack:
parent = stack[-1]
parent.children.append(entity)
entity.parent = parent
else:
file_entities.append(entity)
stack.append(entity)
continue
# 2. Check for Anchor End
match_end = None
if lang == "python":
match_end = patterns["anchor_end"].search(line)
else:
match_end = patterns["html_anchor_end"].search(line) or patterns["js_anchor_end"].search(line)
if match_end:
name = match_end.group("name")
type_ = match_end.group("type")
if not stack:
issues.append(f"{rel_path}:{lineno} Found closing anchor [/DEF:{name}:{type_}] without opening anchor.")
continue
top = stack[-1]
if top.name == name and top.type == type_:
top.end_line = lineno
stack.pop()
else:
issues.append(f"{rel_path}:{lineno} Mismatched closing anchor. Expected [/DEF:{top.name}:{top.type}], found [/DEF:{name}:{type_}].")
continue
# 3. Check for Naked Functions (Missing Contracts)
if "func_def" in patterns:
match_func = patterns["func_def"].search(line)
if match_func:
func_name = match_func.group("name")
is_covered = False
if stack:
current = stack[-1]
# Check if we are inside a Function anchor that matches the name
if current.type == "Function" and current.name == func_name:
is_covered = True
if not is_covered:
issues.append(f"{rel_path}:{lineno} Function '{func_name}' implementation found without matching [DEF:{func_name}:Function] contract.")
# 4. Check for Tags/Relations
if stack:
current = stack[-1]
match_rel = patterns["relation"].search(line)
if match_rel:
current.relations.append({
"type": match_rel.group("type"),
"target": match_rel.group("target")
})
continue
match_tag = None
if lang == "python":
match_tag = patterns["tag"].search(line)
elif lang == "svelte_js":
match_tag = patterns["html_tag"].search(line)
if not match_tag and ("/*" in line or "*" in line or "//" in line):
match_tag = patterns["jsdoc_tag"].search(line)
if match_tag:
tag_name = match_tag.group("tag").upper()
tag_value = match_tag.group("value").strip()
current.tags[tag_name] = tag_value
# Check for belief scope in implementation
if lang == "python" and "belief_scope" in patterns:
if patterns["belief_scope"].search(line):
current.has_belief_scope = True
# End of file check
if stack:
for unclosed in stack:
unclosed.compliance_issues.append(f"Unclosed Anchor at end of file (started line {unclosed.start_line})")
if unclosed.parent is None and unclosed not in file_entities:
file_entities.append(unclosed)
return file_entities, issues
# [/DEF:parse_file:Function]
# [DEF:SemanticMapGenerator:Class]
# @PURPOSE: Orchestrates the mapping process.
class SemanticMapGenerator:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the generator with a root directory.
# @PRE: root_dir is a valid path string.
# @POST: Generator instance is ready.
def __init__(self, root_dir: str):
with belief_scope("__init__"):
self.root_dir = root_dir
self.entities: List[SemanticEntity] = []
self.file_scores: Dict[str, float] = {}
self.global_issues: List[str] = []
self.ignored_patterns = self._load_gitignore()
# [/DEF:__init__:Function]
# [DEF:_load_gitignore:Function]
# @PURPOSE: Loads patterns from .gitignore file.
# @PRE: .gitignore exists in root_dir.
# @POST: Returns set of ignore patterns.
# @RETURN: Set of patterns to ignore.
def _load_gitignore(self) -> Set[str]:
with belief_scope("_load_gitignore"):
patterns = set()
ignore_file = os.path.join(self.root_dir, ".gitignore")
if os.path.exists(ignore_file):
with open(ignore_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
patterns.add(line)
return patterns
# [/DEF:_load_gitignore:Function]
# [DEF:_is_ignored:Function]
# @PURPOSE: Checks if a path should be ignored based on .gitignore or hardcoded defaults.
# @PRE: rel_path is a valid relative path string.
# @POST: Returns True if the path should be ignored.
# @PARAM: rel_path (str) - Path relative to root.
# @RETURN: bool - True if ignored.
def _is_ignored(self, rel_path: str) -> bool:
with belief_scope("_is_ignored"):
# Normalize path for matching
rel_path = rel_path.replace(os.sep, '/')
# Check hardcoded defaults
parts = rel_path.split('/')
for part in parts:
if part in IGNORE_DIRS:
return True
if os.path.basename(rel_path) in IGNORE_FILES:
return True
# Check gitignore patterns
for pattern in self.ignored_patterns:
# Handle directory patterns like 'node_modules/'
if pattern.endswith('/'):
dir_pattern = pattern.rstrip('/')
if rel_path == dir_pattern or rel_path.startswith(pattern):
return True
# Check for patterns in frontend/ or backend/
if rel_path.startswith("frontend/") and fnmatch.fnmatch(rel_path[9:], pattern):
return True
if rel_path.startswith("backend/") and fnmatch.fnmatch(rel_path[8:], pattern):
return True
# Use fnmatch for glob patterns
if fnmatch.fnmatch(rel_path, pattern) or \
fnmatch.fnmatch(os.path.basename(rel_path), pattern) or \
any(fnmatch.fnmatch(part, pattern) for part in parts):
return True
return False
# [/DEF:_is_ignored:Function]
# [DEF:run:Function]
# @PURPOSE: Main execution flow.
# @PRE: Generator is initialized.
# @POST: Semantic map and reports are generated.
# @RELATION: CALLS -> _walk_and_parse
# @RELATION: CALLS -> _generate_artifacts
def run(self):
with belief_scope("run"):
print(f"Starting Semantic Map Generation in {self.root_dir}...")
self._walk_and_parse()
self._generate_artifacts()
print("Done.")
# [/DEF:run:Function]
# [DEF:_walk_and_parse:Function]
# @PURPOSE: Recursively walks directories and triggers parsing.
# @PRE: root_dir exists.
# @POST: All files are scanned and entities extracted.
def _walk_and_parse(self):
with belief_scope("_walk_and_parse"):
for root, dirs, files in os.walk(self.root_dir):
# Optimization: don't enter ignored directories
dirs[:] = [d for d in dirs if not self._is_ignored(os.path.relpath(os.path.join(root, d), self.root_dir) + "/")]
for file in files:
file_path = os.path.join(root, file)
rel_path = os.path.relpath(file_path, self.root_dir)
if self._is_ignored(rel_path):
continue
lang = None
if file.endswith(".py"):
lang = "python"
elif file.endswith((".svelte", ".js", ".ts")):
lang = "svelte_js"
if lang:
entities, issues = parse_file(file_path, rel_path, lang)
self.global_issues.extend(issues)
if entities:
self._process_file_results(rel_path, entities)
# [/DEF:_walk_and_parse:Function]
# [DEF:_process_file_results:Function]
# @PURPOSE: Validates entities and calculates file scores.
# @PRE: Entities have been parsed from the file.
# @POST: File score is calculated and issues collected.
def _process_file_results(self, rel_path: str, entities: List[SemanticEntity]):
with belief_scope("_process_file_results"):
total_score = 0
count = 0
# [DEF:validate_recursive:Function]
# @PURPOSE: Recursively validates a list of entities.
# @PRE: ent_list is a list of SemanticEntity objects.
# @POST: All entities and their children are validated.
def validate_recursive(ent_list):
with belief_scope("validate_recursive"):
nonlocal total_score, count
for e in ent_list:
e.validate()
total_score += e.get_score()
count += 1
validate_recursive(e.children)
# [/DEF:validate_recursive:Function]
validate_recursive(entities)
self.entities.extend(entities)
self.file_scores[rel_path] = (total_score / count) if count > 0 else 0.0
# [/DEF:_process_file_results:Function]
# [DEF:_generate_artifacts:Function]
# @PURPOSE: Writes output files.
# @PRE: Parsing and validation are complete.
# @POST: JSON and Markdown artifacts are written to disk.
def _generate_artifacts(self):
with belief_scope("_generate_artifacts"):
# 1. Full JSON Map
full_map = {
"project_root": self.root_dir,
"generated_at": datetime.datetime.now().isoformat(),
"modules": [e.to_dict() for e in self.entities]
}
os.makedirs(os.path.dirname(OUTPUT_JSON), exist_ok=True)
with open(OUTPUT_JSON, 'w', encoding='utf-8') as f:
json.dump(full_map, f, indent=2)
print(f"Generated {OUTPUT_JSON}")
# 2. Compliance Report
self._generate_report()
# 3. Compressed Map (Markdown)
self._generate_compressed_map()
# [/DEF:_generate_artifacts:Function]
# [DEF:_generate_report:Function]
# @PURPOSE: Generates the Markdown compliance report.
# @PRE: File scores and issues are available.
# @POST: Markdown report is created in reports directory.
def _generate_report(self):
with belief_scope("_generate_report"):
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
report_path = os.path.join(REPORTS_DIR, f"semantic_report_{timestamp}.md")
os.makedirs(REPORTS_DIR, exist_ok=True)
total_files = len(self.file_scores)
avg_score = sum(self.file_scores.values()) / total_files if total_files > 0 else 0
with open(report_path, 'w', encoding='utf-8') as f:
f.write(f"# Semantic Compliance Report\n\n")
f.write(f"**Generated At:** {datetime.datetime.now().isoformat()}\n")
f.write(f"**Global Compliance Score:** {avg_score:.1%}\n")
f.write(f"**Scanned Files:** {total_files}\n\n")
if self.global_issues:
f.write("## Critical Parsing Errors\n")
for issue in self.global_issues:
f.write(f"- 🔴 {issue}\n")
f.write("\n")
f.write("## File Compliance Status\n")
f.write("| File | Score | Issues |\n")
f.write("|------|-------|--------|\n")
sorted_files = sorted(self.file_scores.items(), key=lambda x: x[1])
for file_path, score in sorted_files:
issues = []
self._collect_issues(self.entities, file_path, issues)
status_icon = "🟢" if score == 1.0 else "🟡" if score > 0.5 else "🔴"
issue_text = "<br>".join(issues) if issues else "OK"
f.write(f"| {file_path} | {status_icon} {score:.0%} | {issue_text} |\n")
print(f"Generated {report_path}")
# [/DEF:_generate_report:Function]
# [DEF:_collect_issues:Function]
# @PURPOSE: Helper to collect issues for a specific file from the entity tree.
# @PRE: entities list and file_path are valid.
# @POST: issues list is populated with compliance issues.
def _collect_issues(self, entities: List[SemanticEntity], file_path: str, issues: List[str]):
with belief_scope("_collect_issues"):
for e in entities:
if e.file_path == file_path:
issues.extend([f"[{e.name}] {i}" for i in e.compliance_issues])
self._collect_issues(e.children, file_path, issues)
# [/DEF:_collect_issues:Function]
# [DEF:_generate_compressed_map:Function]
# @PURPOSE: Generates the token-optimized project map.
# @PRE: Entities have been processed.
# @POST: Markdown project map is written.
def _generate_compressed_map(self):
with belief_scope("_generate_compressed_map"):
os.makedirs(os.path.dirname(OUTPUT_COMPRESSED_MD), exist_ok=True)
with open(OUTPUT_COMPRESSED_MD, 'w', encoding='utf-8') as f:
f.write("# Project Semantic Map\n\n")
f.write("> Compressed view for AI Context. Generated automatically.\n\n")
for entity in self.entities:
self._write_entity_md(f, entity, level=0)
print(f"Generated {OUTPUT_COMPRESSED_MD}")
# [/DEF:_generate_compressed_map:Function]
# [DEF:_write_entity_md:Function]
# @PURPOSE: Recursive helper to write entity tree to Markdown.
# @PRE: f is an open file handle, entity is valid.
# @POST: Entity details are written to the file.
def _write_entity_md(self, f, entity: SemanticEntity, level: int):
with belief_scope("_write_entity_md"):
indent = " " * level
icon = "📦"
if entity.type == "Component": icon = "🧩"
elif entity.type == "Function": icon = "ƒ"
elif entity.type == "Class": icon = ""
f.write(f"{indent}- {icon} **{entity.name}** (`{entity.type}`)\n")
purpose = entity.tags.get("PURPOSE") or entity.tags.get("purpose")
layer = entity.tags.get("LAYER") or entity.tags.get("layer")
if purpose:
f.write(f"{indent} - 📝 {purpose}\n")
if layer:
f.write(f"{indent} - 🏗️ Layer: {layer}\n")
for rel in entity.relations:
if rel['type'] in ['DEPENDS_ON', 'CALLS', 'INHERITS_FROM']:
f.write(f"{indent} - 🔗 {rel['type']} -> `{rel['target']}`\n")
if level < 2:
for child in entity.children:
self._write_entity_md(f, child, level + 1)
# [/DEF:_write_entity_md:Function]
# [/DEF:SemanticMapGenerator:Class]
if __name__ == "__main__":
generator = SemanticMapGenerator(PROJECT_ROOT)
generator.run()
# [/DEF:generate_semantic_map:Module]

View File

@@ -1,64 +0,0 @@
# [DEF:get_dataset_structure:Module]
#
# @SEMANTICS: superset, dataset, structure, debug, json
# @PURPOSE: Этот модуль предназначен для получения и сохранения структуры данных датасета из Superset. Он используется для отладки и анализа данных, возвращаемых API.
# @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils.init_clients
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger
# @PUBLIC_API: get_and_save_dataset
# [SECTION: IMPORTS]
import argparse
import json
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.logger import SupersetLogger
# [/SECTION]
# [DEF:get_and_save_dataset:Function]
# @PURPOSE: Получает структуру датасета из Superset и сохраняет ее в JSON-файл.
# @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> superset_client.get_dataset
# @PARAM: env (str) - Среда (dev, prod, и т.д.) для подключения.
# @PARAM: dataset_id (int) - ID датасета для получения.
# @PARAM: output_path (str) - Путь для сохранения JSON-файла.
def get_and_save_dataset(env: str, dataset_id: int, output_path: str):
"""
Получает структуру датасета и сохраняет в файл.
"""
logger = SupersetLogger(name="DatasetStructureRetriever")
logger.info("[get_and_save_dataset][Enter] Starting to fetch dataset structure for ID %d from env '%s'.", dataset_id, env)
try:
clients = setup_clients(logger=logger)
superset_client = clients.get(env)
if not superset_client:
logger.error("[get_and_save_dataset][Failure] Environment '%s' not found.", env)
return
dataset_response = superset_client.get_dataset(dataset_id)
dataset_data = dataset_response.get('result')
if not dataset_data:
logger.error("[get_and_save_dataset][Failure] No result in dataset response.")
return
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(dataset_data, f, ensure_ascii=False, indent=4)
logger.info("[get_and_save_dataset][Success] Dataset structure saved to %s.", output_path)
except Exception as e:
logger.error("[get_and_save_dataset][Failure] An error occurred: %s", e, exc_info=True)
# [/DEF:get_and_save_dataset]
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Получение структуры датасета из Superset.")
parser.add_argument("--dataset-id", required=True, type=int, help="ID датасета.")
parser.add_argument("--env", required=True, help="Среда для подключения (например, dev).")
parser.add_argument("--output-path", default="dataset_structure.json", help="Путь для сохранения JSON-файла.")
args = parser.parse_args()
get_and_save_dataset(args.env, args.dataset_id, args.output_path)
# [/DEF:get_dataset_structure]

View File

@@ -1,401 +0,0 @@
# [DEF:migration_script:Module]
#
# @SEMANTICS: migration, cli, superset, ui, logging, error-recovery, batch-delete
# @PURPOSE: Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок.
# @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils
# @PUBLIC_API: Migration
# [SECTION: IMPORTS]
import json
import logging
import sys
import zipfile
import re
from pathlib import Path
from typing import List, Optional, Tuple, Dict
from superset_tool.client import SupersetClient
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.fileio import create_temp_file, update_yamls, create_dashboard_export
from superset_tool.utils.whiptail_fallback import menu, checklist, yesno, msgbox, inputbox, gauge
from superset_tool.utils.logger import SupersetLogger
# [/SECTION]
# [DEF:Migration:Class]
# @PURPOSE: Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта.
# @RELATION: CREATES_INSTANCE_OF -> SupersetLogger
# @RELATION: USES -> SupersetClient
class Migration:
"""
Интерактивный процесс миграции дашбордов.
"""
# [DEF:Migration.__init__:Function]
# @PURPOSE: Инициализирует сервис миграции, настраивает логгер и начальные состояния.
# @POST: `self.logger` готов к использованию; `enable_delete_on_failure` = `False`.
def __init__(self) -> None:
default_log_dir = Path.cwd() / "logs"
self.logger = SupersetLogger(
name="migration_script",
log_dir=default_log_dir,
level=logging.INFO,
console=True,
)
self.enable_delete_on_failure = False
self.from_c: Optional[SupersetClient] = None
self.to_c: Optional[SupersetClient] = None
self.dashboards_to_migrate: List[dict] = []
self.db_config_replacement: Optional[dict] = None
self._failed_imports: List[dict] = []
# [/DEF:Migration.__init__]
# [DEF:Migration.run:Function]
# @PURPOSE: Точка входа последовательный запуск всех шагов миграции.
# @PRE: Логгер готов.
# @POST: Скрипт завершён, пользователю выведено сообщение.
# @RELATION: CALLS -> self.ask_delete_on_failure
# @RELATION: CALLS -> self.select_environments
# @RELATION: CALLS -> self.select_dashboards
# @RELATION: CALLS -> self.confirm_db_config_replacement
# @RELATION: CALLS -> self.execute_migration
def run(self) -> None:
self.logger.info("[run][Entry] Запуск скрипта миграции.")
self.ask_delete_on_failure()
self.select_environments()
self.select_dashboards()
self.confirm_db_config_replacement()
self.execute_migration()
self.logger.info("[run][Exit] Скрипт миграции завершён.")
# [/DEF:Migration.run]
# [DEF:Migration.ask_delete_on_failure:Function]
# @PURPOSE: Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта.
# @POST: `self.enable_delete_on_failure` установлен.
# @RELATION: CALLS -> yesno
def ask_delete_on_failure(self) -> None:
self.enable_delete_on_failure = yesno(
"Поведение при ошибке импорта",
"Если импорт завершится ошибкой, удалить существующий дашборд и попытаться импортировать заново?",
)
self.logger.info(
"[ask_delete_on_failure][State] Delete-on-failure = %s",
self.enable_delete_on_failure,
)
# [/DEF:Migration.ask_delete_on_failure]
# [DEF:Migration.select_environments:Function]
# @PURPOSE: Позволяет пользователю выбрать исходное и целевое окружения Superset.
# @PRE: `setup_clients` успешно инициализирует все клиенты.
# @POST: `self.from_c` и `self.to_c` установлены.
# @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> menu
def select_environments(self) -> None:
self.logger.info("[select_environments][Entry] Шаг 1/5: Выбор окружений.")
try:
all_clients = setup_clients(self.logger)
available_envs = list(all_clients.keys())
except Exception as e:
self.logger.error("[select_environments][Failure] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось инициализировать клиенты.")
return
rc, from_env_name = menu(
title="Выбор окружения",
prompt="Исходное окружение:",
choices=available_envs,
)
if rc != 0 or from_env_name is None:
self.logger.info("[select_environments][State] Source environment selection cancelled.")
return
self.from_c = all_clients[from_env_name]
self.logger.info("[select_environments][State] from = %s", from_env_name)
available_envs.remove(from_env_name)
rc, to_env_name = menu(
title="Выбор окружения",
prompt="Целевое окружение:",
choices=available_envs,
)
if rc != 0 or to_env_name is None:
self.logger.info("[select_environments][State] Target environment selection cancelled.")
return
self.to_c = all_clients[to_env_name]
self.logger.info("[select_environments][State] to = %s", to_env_name)
self.logger.info("[select_environments][Exit] Шаг 1 завершён.")
# [/DEF:Migration.select_environments]
# [DEF:Migration.select_dashboards:Function]
# @PURPOSE: Позволяет пользователю выбрать набор дашбордов для миграции.
# @PRE: `self.from_c` инициализирован.
# @POST: `self.dashboards_to_migrate` заполнен.
# @RELATION: CALLS -> self.from_c.get_dashboards
# @RELATION: CALLS -> checklist
def select_dashboards(self) -> None:
self.logger.info("[select_dashboards][Entry] Шаг 2/5: Выбор дашбордов.")
if self.from_c is None:
self.logger.error("[select_dashboards][Failure] Source client not initialized.")
msgbox("Ошибка", "Исходное окружение не выбрано.")
return
try:
_, all_dashboards = self.from_c.get_dashboards()
if not all_dashboards:
self.logger.warning("[select_dashboards][State] No dashboards.")
msgbox("Информация", "В исходном окружении нет дашбордов.")
return
rc, regex = inputbox("Поиск", "Введите регулярное выражение для поиска дашбордов:")
if rc != 0:
return
# Ensure regex is a string and perform caseinsensitive search
regex_str = str(regex)
filtered_dashboards = [
d for d in all_dashboards if re.search(regex_str, d["dashboard_title"], re.IGNORECASE)
]
options = [("ALL", "Все дашборды")] + [
(str(d["id"]), d["dashboard_title"]) for d in filtered_dashboards
]
rc, selected = checklist(
title="Выбор дашбордов",
prompt="Отметьте нужные дашборды (введите номера):",
options=options,
)
if rc != 0:
return
if "ALL" in selected:
self.dashboards_to_migrate = filtered_dashboards
else:
self.dashboards_to_migrate = [
d for d in filtered_dashboards if str(d["id"]) in selected
]
self.logger.info(
"[select_dashboards][State] Выбрано %d дашбордов.",
len(self.dashboards_to_migrate),
)
except Exception as e:
self.logger.error("[select_dashboards][Failure] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось получить список дашбордов.")
self.logger.info("[select_dashboards][Exit] Шаг 2 завершён.")
# [/DEF:Migration.select_dashboards]
# [DEF:Migration.confirm_db_config_replacement:Function]
# @PURPOSE: Запрашивает у пользователя, требуется ли заменить имена БД в YAML-файлах.
# @POST: `self.db_config_replacement` либо `None`, либо заполнен.
# @RELATION: CALLS -> yesno
# @RELATION: CALLS -> self._select_databases
def confirm_db_config_replacement(self) -> None:
if yesno("Замена БД", "Заменить конфигурацию БД в YAMLфайлах?"):
old_db, new_db = self._select_databases()
if not old_db or not new_db:
self.logger.info("[confirm_db_config_replacement][State] Selection cancelled.")
return
print(f"old_db: {old_db}")
old_result = old_db.get("result", {})
new_result = new_db.get("result", {})
self.db_config_replacement = {
"old": {
"database_name": old_result.get("database_name"),
"uuid": old_result.get("uuid"),
"database_uuid": old_result.get("uuid"),
"id": str(old_db.get("id"))
},
"new": {
"database_name": new_result.get("database_name"),
"uuid": new_result.get("uuid"),
"database_uuid": new_result.get("uuid"),
"id": str(new_db.get("id"))
}
}
self.logger.info("[confirm_db_config_replacement][State] Replacement set: %s", self.db_config_replacement)
else:
self.logger.info("[confirm_db_config_replacement][State] Skipped.")
# [/DEF:Migration.confirm_db_config_replacement]
# [DEF:Migration._select_databases:Function]
# @PURPOSE: Позволяет пользователю выбрать исходную и целевую БД через API.
# @POST: Возвращает кортеж (старая БД, новая БД) или (None, None) при отмене.
# @RELATION: CALLS -> self.from_c.get_databases
# @RELATION: CALLS -> self.to_c.get_databases
# @RELATION: CALLS -> self.from_c.get_database
# @RELATION: CALLS -> self.to_c.get_database
# @RELATION: CALLS -> menu
def _select_databases(self) -> Tuple[Optional[Dict], Optional[Dict]]:
self.logger.info("[_select_databases][Entry] Selecting databases from both environments.")
if self.from_c is None or self.to_c is None:
self.logger.error("[_select_databases][Failure] Source or target client not initialized.")
msgbox("Ошибка", "Исходное или целевое окружение не выбрано.")
return None, None
# Получаем список БД из обоих окружений
try:
_, from_dbs = self.from_c.get_databases()
_, to_dbs = self.to_c.get_databases()
except Exception as e:
self.logger.error("[_select_databases][Failure] Failed to fetch databases: %s", e)
msgbox("Ошибка", "Не удалось получить список баз данных.")
return None, None
# Формируем список для выбора
# По Swagger документации, в ответе API поле называется "database_name"
from_choices = []
for db in from_dbs:
db_name = db.get("database_name", "Без имени")
from_choices.append((str(db["id"]), f"{db_name} (ID: {db['id']})"))
to_choices = []
for db in to_dbs:
db_name = db.get("database_name", "Без имени")
to_choices.append((str(db["id"]), f"{db_name} (ID: {db['id']})"))
# Показываем список БД для исходного окружения
rc, from_sel = menu(
title="Выбор исходной БД",
prompt="Выберите исходную БД:",
choices=[f"{name}" for id, name in from_choices]
)
if rc != 0:
return None, None
# Определяем выбранную БД
from_db_id = from_choices[[choice[1] for choice in from_choices].index(from_sel)][0]
# Получаем полную информацию о выбранной БД из исходного окружения
try:
from_db = self.from_c.get_database(int(from_db_id))
except Exception as e:
self.logger.error("[_select_databases][Failure] Failed to fetch database details: %s", e)
msgbox("Ошибка", "Не удалось получить информацию о выбранной базе данных.")
return None, None
# Показываем список БД для целевого окружения
rc, to_sel = menu(
title="Выбор целевой БД",
prompt="Выберите целевую БД:",
choices=[f"{name}" for id, name in to_choices]
)
if rc != 0:
return None, None
# Определяем выбранную БД
to_db_id = to_choices[[choice[1] for choice in to_choices].index(to_sel)][0]
# Получаем полную информацию о выбранной БД из целевого окружения
try:
to_db = self.to_c.get_database(int(to_db_id))
except Exception as e:
self.logger.error("[_select_databases][Failure] Failed to fetch database details: %s", e)
msgbox("Ошибка", "Не удалось получить информацию о выбранной базе данных.")
return None, None
self.logger.info("[_select_databases][Exit] Selected databases: %s -> %s", from_db.get("database_name", "Без имени"), to_db.get("database_name", "Без имени"))
return from_db, to_db
# [/DEF:Migration._select_databases]
# [DEF:Migration._batch_delete_by_ids:Function]
# @PURPOSE: Удаляет набор дашбордов по их ID единым запросом.
# @PRE: `ids` непустой список целых чисел.
# @POST: Все указанные дашборды удалены (если они существовали).
# @RELATION: CALLS -> self.to_c.network.request
# @PARAM: ids (List[int]) - Список ID дашбордов для удаления.
def _batch_delete_by_ids(self, ids: List[int]) -> None:
if not ids:
self.logger.debug("[_batch_delete_by_ids][Skip] Empty ID list nothing to delete.")
return
if self.to_c is None:
self.logger.error("[_batch_delete_by_ids][Failure] Target client not initialized.")
msgbox("Ошибка", "Целевое окружение не выбрано.")
return
self.logger.info("[_batch_delete_by_ids][Entry] Deleting dashboards IDs: %s", ids)
q_param = json.dumps(ids)
response = self.to_c.network.request(method="DELETE", endpoint="/dashboard/", params={"q": q_param})
if isinstance(response, dict) and response.get("result", True) is False:
self.logger.warning("[_batch_delete_by_ids][Warning] Unexpected delete response: %s", response)
else:
self.logger.info("[_batch_delete_by_ids][Success] Delete request completed.")
# [/DEF:Migration._batch_delete_by_ids]
# [DEF:Migration.execute_migration:Function]
# @PURPOSE: Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления.
# @PRE: `self.dashboards_to_migrate` не пуст; `self.from_c` и `self.to_c` инициализированы.
# @POST: Успешные дашборды импортированы; неудачные - восстановлены или залогированы.
# @RELATION: CALLS -> self.from_c.export_dashboard
# @RELATION: CALLS -> create_temp_file
# @RELATION: CALLS -> update_yamls
# @RELATION: CALLS -> create_dashboard_export
# @RELATION: CALLS -> self.to_c.import_dashboard
# @RELATION: CALLS -> self._batch_delete_by_ids
def execute_migration(self) -> None:
if not self.dashboards_to_migrate:
self.logger.warning("[execute_migration][Skip] No dashboards to migrate.")
msgbox("Информация", "Нет дашбордов для миграции.")
return
if self.from_c is None or self.to_c is None:
self.logger.error("[execute_migration][Failure] Source or target client not initialized.")
msgbox("Ошибка", "Исходное или целевое окружение не выбрано.")
return
total = len(self.dashboards_to_migrate)
self.logger.info("[execute_migration][Entry] Starting migration of %d dashboards.", total)
self.to_c.delete_before_reimport = self.enable_delete_on_failure
with gauge("Миграция...", width=60, height=10) as g:
for i, dash in enumerate(self.dashboards_to_migrate):
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"]
g.set_text(f"Миграция: {title} ({i + 1}/{total})")
g.set_percent(int((i / total) * 100))
exported_content = None # Initialize exported_content
try:
exported_content, _ = self.from_c.export_dashboard(dash_id)
with create_temp_file(content=exported_content, dry_run=True, suffix=".zip", logger=self.logger) as tmp_zip_path, \
create_temp_file(suffix=".dir", logger=self.logger) as tmp_unpack_dir:
if not self.db_config_replacement:
self.to_c.import_dashboard(file_name=tmp_zip_path, dash_id=dash_id, dash_slug=dash_slug)
else:
with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_unpack_dir)
if self.db_config_replacement:
update_yamls(db_configs=[self.db_config_replacement], path=str(tmp_unpack_dir))
with create_temp_file(suffix=".zip", dry_run=True, logger=self.logger) as tmp_new_zip:
create_dashboard_export(zip_path=tmp_new_zip, source_paths=[str(p) for p in Path(tmp_unpack_dir).glob("**/*")])
self.to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
self.logger.info("[execute_migration][Success] Dashboard %s imported.", title)
except Exception as exc:
self.logger.error("[execute_migration][Failure] %s", exc, exc_info=True)
self._failed_imports.append({"slug": dash_slug, "dash_id": dash_id, "zip_content": exported_content})
msgbox("Ошибка", f"Не удалось мигрировать дашборд {title}.\n\n{exc}")
g.set_percent(100)
if self.enable_delete_on_failure and self._failed_imports:
self.logger.info("[execute_migration][Recovery] %d dashboards failed. Starting recovery.", len(self._failed_imports))
_, target_dashboards = self.to_c.get_dashboards()
slug_to_id = {d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d}
ids_to_delete = [slug_to_id[f["slug"]] for f in self._failed_imports if f["slug"] in slug_to_id]
self._batch_delete_by_ids(ids_to_delete)
for fail in self._failed_imports:
with create_temp_file(content=fail["zip_content"], suffix=".zip", logger=self.logger) as retry_zip:
self.to_c.import_dashboard(file_name=retry_zip, dash_id=fail["dash_id"], dash_slug=fail["slug"])
self.logger.info("[execute_migration][Recovered] Dashboard slug '%s' re-imported.", fail["slug"])
self.logger.info("[execute_migration][Exit] Migration finished.")
msgbox("Информация", "Миграция завершена!")
# [/DEF:Migration.execute_migration]
# [/DEF:Migration]
if __name__ == "__main__":
Migration().run()
# [/DEF:migration_script]

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