From ce3955ed2e5996baea70ae771f33a33f114e218d Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 3 Mar 2026 19:51:17 +0300 Subject: [PATCH] chore: commit remaining workspace changes --- .kilocode/rules/specify-rules.md | 4 +- README.md | 310 ++++--- backend/git_repos/10 | 2 +- backend/src/api/routes/dashboards.py | 111 ++- backend/src/api/routes/environments.py | 14 +- backend/src/api/routes/settings.py | 112 ++- backend/src/core/utils/network.py | 39 +- backend/src/services/git_service.py | 55 +- .../tests/core/test_git_service_gitea_pr.py | 67 ++ docs/architecture.md | 0 docs/installation.md | 453 ++++++++++ .../src/components/git/DeploymentModal.svelte | 22 +- frontend/src/components/git/GitManager.svelte | 777 +++++++++++------- frontend/src/lib/api.js | 15 + frontend/src/routes/dashboards/+page.svelte | 190 +++-- .../src/routes/dashboards/[id]/+page.svelte | 78 +- frontend/src/routes/settings/+page.svelte | 10 + 17 files changed, 1679 insertions(+), 580 deletions(-) create mode 100644 backend/tests/core/test_git_service_gitea_pr.py create mode 100644 docs/architecture.md create mode 100644 docs/installation.md diff --git a/.kilocode/rules/specify-rules.md b/.kilocode/rules/specify-rules.md index ff9a909..a1b4c85 100644 --- a/.kilocode/rules/specify-rules.md +++ b/.kilocode/rules/specify-rules.md @@ -45,6 +45,8 @@ Auto-generated from all feature plans. Last updated: 2025-12-19 - SQLite task/result persistence (existing task DB), filesystem only for existing artifacts (no new primary store required) (020-task-reports-design) - Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui` (001-unify-frontend-style) - N/A (UI styling and component behavior only) (001-unify-frontend-style) +- Python 3.9+ (backend scripts/services), Shell (release tooling) + FastAPI stack (existing backend), ConfigManager, TaskManager, файловые утилиты, internal artifact registries (020-clean-repo-enterprise) +- PostgreSQL (конфигурации/метаданные), filesystem (артефакты дистрибутива, отчёты проверки) (020-clean-repo-enterprise) - Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui) @@ -65,9 +67,9 @@ cd src; pytest; ruff check . Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions ## Recent Changes +- 020-clean-repo-enterprise: Added Python 3.9+ (backend scripts/services), Shell (release tooling) + FastAPI stack (existing backend), ConfigManager, TaskManager, файловые утилиты, internal artifact registries - 001-unify-frontend-style: Added Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui` - 020-task-reports-design: Added Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack -- 019-superset-ux-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing) diff --git a/README.md b/README.md index 54d414d..a1188ed 100755 --- a/README.md +++ b/README.md @@ -1,143 +1,253 @@ # ss-tools -Инструменты автоматизации для Apache Superset: миграция, маппинг, хранение артефактов, Git-интеграция, отчеты по задачам и LLM-assistant. +**Инструменты автоматизации для Apache Superset: миграция, версионирование, аналитика и управление данными** -## Возможности -- Миграция дашбордов и датасетов между окружениями. -- Ручной и полуавтоматический маппинг ресурсов. -- Логи фоновых задач и отчеты о выполнении. -- Локальное хранилище файлов и бэкапов. -- Git-операции по Superset-ассетам через UI. -- Модуль LLM-анализа и assistant API. -- Многопользовательская авторизация (RBAC). +## 📋 О проекте -## Стек -- Backend: Python, FastAPI, SQLAlchemy, APScheduler. -- Frontend: SvelteKit, Vite, Tailwind CSS. -- База данных: PostgreSQL (основная конфигурация), поддержка миграции с legacy SQLite. +ss-tools — это комплексная платформа для автоматизации работы с Apache Superset, предоставляющая инструменты для миграции дашбордов, управления версиями через Git, LLM-анализа данных и многопользовательского контроля доступа. Система построена на модульной архитектуре с плагинной системой расширений. -## Структура репозитория -- `backend/` — API, плагины, сервисы, скрипты миграции и тесты. -- `frontend/` — SPA-интерфейс (SvelteKit). -- `docs/` — документация по архитектуре и плагинам. -- `specs/` — спецификации и планы реализации. -- `docker/` и `docker-compose.yml` — контейнеризация. +### 🎯 Ключевые возможности -## Быстрый старт (локально) +#### 🔄 Миграция данных +- **Миграция дашбордов и датасетов** между окружениями (dev/staging/prod) +- **Dry-run режим** с детальным анализом рисков и предпросмотром изменений +- **Автоматическое маппинг** баз данных и ресурсов между окружениями +- **Поддержка legacy-данных** с миграцией из SQLite в PostgreSQL + +#### 🌿 Git-интеграция +- **Версионирование** дашбордов через Git-репозитории +- **Управление ветками** и коммитами с помощью LLM +- **Деплой** дашбордов из Git в целевые окружения +- **История изменений** с детальным diff + +#### 🤖 LLM-аналитика +- **Автоматическая валидация** дашбордов с помощью ИИ +- **Генерация документации** для датасетов +- **Assistant API** для natural language команд +- **Интеллектуальное коммитинг** с подсказками сообщений + +#### 📊 Управление и мониторинг +- **Многопользовательская авторизация** (RBAC) +- **Фоновые задачи** с реальным логированием через WebSocket +- **Унифицированные отчеты** по выполненным задачам +- **Хранение артефактов** с политиками retention +- **Аудит логирование** всех действий + +#### 🔌 Плагины +- **MigrationPlugin** — миграция дашбордов +- **BackupPlugin** — резервное копирование +- **GitPlugin** — управление версиями +- **LLMAnalysisPlugin** — аналитика и документация +- **MapperPlugin** — маппинг колонок +- **DebugPlugin** — диагностика системы +- **SearchPlugin** — поиск по датасетам + +## 🏗️ Архитектура + +### Технологический стек + +**Backend:** +- Python 3.9+ (FastAPI, SQLAlchemy, APScheduler) +- PostgreSQL (основная БД) +- GitPython для Git-операций +- OpenAI API для LLM-функций +- Playwright для скриншотов + +**Frontend:** +- SvelteKit (Svelte 5.x) +- Vite +- Tailwind CSS +- WebSocket для реального логирования + +**DevOps:** +- Docker & Docker Compose +- PostgreSQL 16 + +### Модульная структура + +``` +ss-tools/ +├── backend/ # Backend API +│ ├── src/ +│ │ ├── api/ # API маршруты +│ │ ├── core/ # Ядро системы +│ │ │ ├── task_manager/ # Управление задачами +│ │ │ ├── auth/ # Авторизация +│ │ │ ├── migration/ # Миграция данных +│ │ │ └── plugins/ # Плагины +│ │ ├── models/ # Модели данных +│ │ ├── services/ # Бизнес-логика +│ │ └── schemas/ # Pydantic схемы +│ └── tests/ # Тесты +├── frontend/ # SvelteKit приложение +│ ├── src/ +│ │ ├── routes/ # Страницы +│ │ ├── lib/ +│ │ │ ├── components/ # UI компоненты +│ │ │ ├── stores/ # Svelte stores +│ │ │ └── api/ # API клиент +│ │ └── i18n/ # Мультиязычность +│ └── tests/ +├── docker/ # Docker конфигурация +├── docs/ # Документация +└── specs/ # Спецификации +``` + +## 🚀 Быстрый старт ### Требования + +**Локальная разработка:** - Python 3.9+ - Node.js 18+ - npm +- 2 GB RAM (минимум) +- 5 GB свободного места + +**Docker (рекомендуется):** +- Docker Engine 24+ +- Docker Compose v2 +- 4 GB RAM (для стабильной работы) + +### Установка и запуск + +#### Вариант 1: Docker (рекомендуется) -### Запуск backend + frontend одним скриптом ```bash -./run.sh -``` +# Клонирование репозитория +git clone +cd ss-tools -Что делает `run.sh`: -- проверяет версии Python/npm; -- создает `backend/.venv` (если нет); -- устанавливает `backend/requirements.txt` и `frontend` зависимости; -- запускает backend и frontend параллельно. - -Опции: -- `./run.sh --skip-install` — пропустить установку зависимостей. -- `./run.sh --help` — показать справку. - -Переменные окружения для локального запуска: -- `BACKEND_PORT` (по умолчанию `8000`) -- `FRONTEND_PORT` (по умолчанию `5173`) -- `POSTGRES_URL` -- `DATABASE_URL` -- `TASKS_DATABASE_URL` -- `AUTH_DATABASE_URL` - -## Docker - -### Запуск -```bash +# Запуск всех сервисов docker compose up --build + +# После запуска: +# Frontend: http://localhost:8000 +# Backend API: http://localhost:8001 +# PostgreSQL: localhost:5432 ``` -После старта сервисы доступны по адресам: -- Frontend: `http://localhost:8000` -- Backend API: `http://localhost:8001` -- PostgreSQL: `localhost:5432` (`postgres/postgres`, БД `ss_tools`) +#### Вариант 2: Локально -### Остановка -```bash -docker compose down -``` - -### Очистка БД-тома -```bash -docker compose down -v -``` - -### Альтернативный образ PostgreSQL -Если есть проблемы с pull `postgres:16-alpine`: -```bash -POSTGRES_IMAGE=mirror.gcr.io/library/postgres:16-alpine docker compose up -d db -``` -или -```bash -POSTGRES_IMAGE=bitnami/postgresql:latest docker compose up -d db -``` - -Если порт `5432` занят: -```bash -POSTGRES_HOST_PORT=5433 docker compose up -d db -``` - -## Разработка - -### Ручной запуск сервисов ```bash +# Backend cd backend python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt python3 -m uvicorn src.app:app --reload --port 8000 -``` -В другом терминале: -```bash +# Frontend (в новом терминале) cd frontend npm install npm run dev -- --port 5173 ``` -### Тесты -Backend: -```bash -cd backend -source .venv/bin/activate -pytest -``` +### Первичная настройка -Frontend: -```bash -cd frontend -npm run test -``` - -## Инициализация auth (опционально) ```bash +# Инициализация БД cd backend source .venv/bin/activate python src/scripts/init_auth_db.py + +# Создание администратора python src/scripts/create_admin.py --username admin --password admin ``` -## Миграция legacy-данных (опционально) +## 📖 Документация + +- [Установка и настройка](docs/installation.md) +- [Архитектура системы](docs/architecture.md) +- [Разработка плагинов](docs/plugin_dev.md) +- [API документация](http://localhost:8001/docs) +- [Настройка окружений](docs/settings.md) + +## 🧪 Тестирование + ```bash +# Backend тесты cd backend source .venv/bin/activate -PYTHONPATH=. python src/scripts/migrate_sqlite_to_postgres.py --sqlite-path tasks.db +pytest + +# Frontend тесты +cd frontend +npm run test + +# Запуск конкретного теста +pytest tests/test_auth.py::test_create_user ``` -## Дополнительная документация -- `docs/plugin_dev.md` -- `docs/settings.md` -- `semantic_protocol.md` + + +## 🔐 Авторизация + +Система поддерживает два метода аутентификации: + +1. **Локальная аутентификация** (username/password) +2. **ADFS SSO** (Active Directory Federation Services) + +### Управление пользователями и ролями + +```bash +# Получение списка пользователей +GET /api/admin/users + +# Создание пользователя +POST /api/admin/users +{ + "username": "newuser", + "email": "user@example.com", + "password": "password123", + "roles": ["analyst"] +} + +# Создание роли +POST /api/admin/roles +{ + "name": "analyst", + "permissions": ["dashboards:read", "dashboards:write"] +} +``` + +## 📊 Мониторинг + +### Отчеты о задачах + +```bash +# Список всех отчетов +GET /api/reports?page=1&page_size=20 + +# Детали отчета +GET /api/reports/{report_id} + +# Фильтры +GET /api/reports?status=failed&task_type=validation&date_from=2024-01-01 +``` + +### Активность + +- **Dashboard Hub** — управление дашбордами с Git-статусом +- **Dataset Hub** — управление датасетами с прогрессом маппинга +- **Task Drawer** — мониторинг выполнения фоновых задач +- **Unified Reports** — унифицированные отчеты по всем типам задач + +## 🔄 Обновление системы + +```bash +# Обновление Docker контейнеров +docker compose pull +docker compose up -d + +# Обновление зависимостей Python +cd backend +source .venv/bin/activate +pip install -r requirements.txt --upgrade + +# Обновление зависимостей Node.js +cd frontend +npm install +``` + + diff --git a/backend/git_repos/10 b/backend/git_repos/10 index 3c0ade6..dec2896 160000 --- a/backend/git_repos/10 +++ b/backend/git_repos/10 @@ -1 +1 @@ -Subproject commit 3c0ade67f99fc538562be23f5ef3591dbeeca3b9 +Subproject commit dec289695ffe3ddf27acbce8106d20e3a524be89 diff --git a/backend/src/api/routes/dashboards.py b/backend/src/api/routes/dashboards.py index 2714dec..ddd5954 100644 --- a/backend/src/api/routes/dashboards.py +++ b/backend/src/api/routes/dashboards.py @@ -206,6 +206,42 @@ def _resolve_dashboard_id_from_ref( raise HTTPException(status_code=404, detail="Dashboard not found") # [/DEF:_resolve_dashboard_id_from_ref:Function] + +# [DEF:_normalize_filter_values:Function] +# @PURPOSE: Normalize query filter values to lower-cased non-empty tokens. +# @PRE: values may be None or list of strings. +# @POST: Returns trimmed normalized list preserving input order. +def _normalize_filter_values(values: Optional[List[str]]) -> List[str]: + if not values: + return [] + normalized: List[str] = [] + for value in values: + token = str(value or "").strip().lower() + if token: + normalized.append(token) + return normalized +# [/DEF:_normalize_filter_values:Function] + + +# [DEF:_dashboard_git_filter_value:Function] +# @PURPOSE: Build comparable git status token for dashboards filtering. +# @PRE: dashboard payload may contain git_status or None. +# @POST: Returns one of ok|diff|no_repo|error|pending. +def _dashboard_git_filter_value(dashboard: Dict[str, Any]) -> str: + git_status = dashboard.get("git_status") or {} + sync_status = str(git_status.get("sync_status") or "").strip().upper() + has_repo = git_status.get("has_repo") + if has_repo is False or sync_status == "NO_REPO": + return "no_repo" + if sync_status == "DIFF": + return "diff" + if sync_status == "OK": + return "ok" + if sync_status == "ERROR": + return "error" + return "pending" +# [/DEF:_dashboard_git_filter_value:Function] + # [DEF:get_dashboards:Function] # @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status # @PRE: env_id must be a valid environment ID @@ -225,6 +261,11 @@ async def get_dashboards( search: Optional[str] = None, page: int = 1, page_size: int = 10, + filter_title: Optional[List[str]] = Query(default=None), + filter_git_status: Optional[List[str]] = Query(default=None), + filter_llm_status: Optional[List[str]] = Query(default=None), + filter_changed_on: Optional[List[str]] = Query(default=None), + filter_actor: Optional[List[str]] = Query(default=None), config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager), resource_service=Depends(get_resource_service), @@ -249,9 +290,23 @@ async def get_dashboards( try: # Get all tasks for status lookup all_tasks = task_manager.get_all_tasks() + title_filters = _normalize_filter_values(filter_title) + git_filters = _normalize_filter_values(filter_git_status) + llm_filters = _normalize_filter_values(filter_llm_status) + changed_on_filters = _normalize_filter_values(filter_changed_on) + actor_filters = _normalize_filter_values(filter_actor) + has_column_filters = any( + ( + title_filters, + git_filters, + llm_filters, + changed_on_filters, + actor_filters, + ) + ) # Fast path: real ResourceService -> one Superset page call per API request. - if isinstance(resource_service, ResourceService): + if isinstance(resource_service, ResourceService) and not has_column_filters: try: page_payload = await resource_service.get_dashboards_page_with_status( env, @@ -288,6 +343,60 @@ async def get_dashboards( start_idx = (page - 1) * page_size end_idx = start_idx + page_size paginated_dashboards = dashboards[start_idx:end_idx] + elif isinstance(resource_service, ResourceService) and has_column_filters: + dashboards = await resource_service.get_dashboards_with_status( + env, + all_tasks, + include_git_status=bool(git_filters), + ) + + if search: + search_lower = search.lower() + dashboards = [ + d for d in dashboards + if search_lower in d.get("title", "").lower() + or search_lower in d.get("slug", "").lower() + ] + + def _matches_dashboard_filters(dashboard: Dict[str, Any]) -> bool: + title_value = str(dashboard.get("title") or "").strip().lower() + if title_filters and title_value not in title_filters: + return False + + if git_filters: + git_value = _dashboard_git_filter_value(dashboard) + if git_value not in git_filters: + return False + + llm_value = str( + ((dashboard.get("last_task") or {}).get("validation_status")) + or "UNKNOWN" + ).strip().lower() + if llm_filters and llm_value not in llm_filters: + return False + + changed_on_raw = str(dashboard.get("last_modified") or "").strip().lower() + changed_on_prefix = changed_on_raw[:10] if len(changed_on_raw) >= 10 else changed_on_raw + if changed_on_filters and changed_on_raw not in changed_on_filters and changed_on_prefix not in changed_on_filters: + return False + + owners = dashboard.get("owners") or [] + if isinstance(owners, list): + actor_value = ", ".join(str(item).strip() for item in owners if str(item).strip()).lower() + else: + actor_value = str(owners).strip().lower() + if not actor_value: + actor_value = "-" + if actor_filters and actor_value not in actor_filters: + return False + return True + + dashboards = [d for d in dashboards if _matches_dashboard_filters(d)] + total = len(dashboards) + total_pages = (total + page_size - 1) // page_size if total > 0 else 1 + start_idx = (page - 1) * page_size + end_idx = start_idx + page_size + paginated_dashboards = dashboards[start_idx:end_idx] else: # Compatibility path for mocked services in route tests. dashboards = await resource_service.get_dashboards_with_status( diff --git a/backend/src/api/routes/environments.py b/backend/src/api/routes/environments.py index 90abbac..349a779 100644 --- a/backend/src/api/routes/environments.py +++ b/backend/src/api/routes/environments.py @@ -20,6 +20,18 @@ from ...core.logger import belief_scope router = APIRouter(prefix="/api/environments", tags=["Environments"]) + +# [DEF:_normalize_superset_env_url:Function] +# @PURPOSE: Canonicalize Superset environment URL to base host/path without trailing /api/v1. +# @PRE: raw_url can be empty. +# @POST: Returns normalized base URL. +def _normalize_superset_env_url(raw_url: str) -> str: + normalized = str(raw_url or "").strip().rstrip("/") + if normalized.lower().endswith("/api/v1"): + normalized = normalized[:-len("/api/v1")] + return normalized.rstrip("/") +# [/DEF:_normalize_superset_env_url:Function] + # [DEF:ScheduleSchema:DataClass] class ScheduleSchema(BaseModel): enabled: bool = False @@ -70,7 +82,7 @@ async def get_environments( EnvironmentResponse( id=e.id, name=e.name, - url=e.url, + url=_normalize_superset_env_url(e.url), stage=resolved_stage, is_production=(resolved_stage == "PROD"), backup_schedule=ScheduleSchema( diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index 02b13f3..a901fc7 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -31,7 +31,38 @@ class LoggingConfigResponse(BaseModel): enable_belief_state: bool # [/DEF:LoggingConfigResponse:Class] -router = APIRouter() +router = APIRouter() + + +# [DEF:_normalize_superset_env_url:Function] +# @PURPOSE: Canonicalize Superset environment URL to base host/path without trailing /api/v1. +# @PRE: raw_url can be empty. +# @POST: Returns normalized base URL. +def _normalize_superset_env_url(raw_url: str) -> str: + normalized = str(raw_url or "").strip().rstrip("/") + if normalized.lower().endswith("/api/v1"): + normalized = normalized[:-len("/api/v1")] + return normalized.rstrip("/") +# [/DEF:_normalize_superset_env_url:Function] + + +# [DEF:_validate_superset_connection_fast:Function] +# @PURPOSE: Run lightweight Superset connectivity validation without full pagination scan. +# @PRE: env contains valid URL and credentials. +# @POST: Raises on auth/API failures; returns None on success. +def _validate_superset_connection_fast(env: Environment) -> None: + client = SupersetClient(env) + # 1) Explicit auth check + client.authenticate() + # 2) Single lightweight API call to ensure read access + client.get_dashboards_page( + query={ + "page": 0, + "page_size": 1, + "columns": ["id"], + } + ) +# [/DEF:_validate_superset_connection_fast:Function] # [DEF:get_settings:Function] # @PURPOSE: Retrieves all application settings. @@ -112,14 +143,18 @@ async def update_storage_settings( # @PRE: Config manager is available. # @POST: Returns list of environments. # @RETURN: List[Environment] - List of environments. -@router.get("/environments", response_model=List[Environment]) -async def get_environments( +@router.get("/environments", response_model=List[Environment]) +async def get_environments( config_manager: ConfigManager = Depends(get_config_manager), _ = Depends(has_permission("admin:settings", "READ")) -): - with belief_scope("get_environments"): - logger.info("[get_environments][Entry] Fetching environments") - return config_manager.get_environments() +): + with belief_scope("get_environments"): + logger.info("[get_environments][Entry] Fetching environments") + environments = config_manager.get_environments() + return [ + env.copy(update={"url": _normalize_superset_env_url(env.url)}) + for env in environments + ] # [/DEF:get_environments:Function] # [DEF:add_environment:Function] @@ -129,21 +164,21 @@ async def get_environments( # @PARAM: env (Environment) - The environment to add. # @RETURN: Environment - The added environment. @router.post("/environments", response_model=Environment) -async def add_environment( - env: Environment, +async def add_environment( + env: Environment, config_manager: ConfigManager = Depends(get_config_manager), _ = Depends(has_permission("admin:settings", "WRITE")) -): - with belief_scope("add_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}") + env = env.copy(update={"url": _normalize_superset_env_url(env.url)}) - # Validate connection before adding - try: - client = SupersetClient(env) - client.get_dashboards(query={"page_size": 1}) - except Exception as e: - logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}") - raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}") + # Validate connection before adding (fast path) + try: + _validate_superset_connection_fast(env) + except Exception as e: + logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}") + raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}") config_manager.add_environment(env) return env @@ -157,28 +192,29 @@ async def add_environment( # @PARAM: env (Environment) - The updated environment data. # @RETURN: Environment - The updated environment. @router.put("/environments/{id}", response_model=Environment) -async def update_environment( +async def update_environment( id: str, env: Environment, config_manager: ConfigManager = Depends(get_config_manager) -): +): 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 - env_to_validate = env.copy(deep=True) + env = env.copy(update={"url": _normalize_superset_env_url(env.url)}) + + # If password is masked, we need the real one for validation + env_to_validate = env.copy(deep=True) if env_to_validate.password == "********": old_env = next((e for e in config_manager.get_environments() if e.id == id), None) if old_env: env_to_validate.password = old_env.password - # Validate connection before updating - try: - client = SupersetClient(env_to_validate) - client.get_dashboards(query={"page_size": 1}) - except Exception as e: - logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}") - raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}") + # Validate connection before updating (fast path) + try: + _validate_superset_connection_fast(env_to_validate) + except Exception as e: + logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}") + raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}") if config_manager.update_environment(id, env): return env @@ -208,7 +244,7 @@ async def delete_environment( # @PARAM: id (str) - The ID of the environment to test. # @RETURN: dict - Success message or error. @router.post("/environments/{id}/test") -async def test_environment_connection( +async def test_environment_connection( id: str, config_manager: ConfigManager = Depends(get_config_manager) ): @@ -220,15 +256,11 @@ async def test_environment_connection( if not env: raise HTTPException(status_code=404, detail=f"Environment {id} not found") - try: - # Initialize client (this will trigger authentication) - client = SupersetClient(env) - - # Try a simple request to verify - client.get_dashboards(query={"page_size": 1}) - - logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}") - return {"status": "success", "message": "Connection successful"} + try: + _validate_superset_connection_fast(env) + + logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}") + return {"status": "success", "message": "Connection successful"} except Exception as e: logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}") return {"status": "error", "message": str(e)} diff --git a/backend/src/core/utils/network.py b/backend/src/core/utils/network.py index 361889c..33ef124 100644 --- a/backend/src/core/utils/network.py +++ b/backend/src/core/utils/network.py @@ -101,7 +101,8 @@ class APIClient: def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT): with belief_scope("__init__"): app_logger.info("[APIClient.__init__][Entry] Initializing APIClient.") - self.base_url: str = config.get("base_url", "") + self.base_url: str = self._normalize_base_url(config.get("base_url", "")) + self.api_base_url: str = f"{self.base_url}/api/v1" self.auth = config.get("auth") self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout} self.session = self._init_session() @@ -156,6 +157,34 @@ class APIClient: return session # [/DEF:_init_session:Function] + # [DEF:_normalize_base_url:Function] + # @PURPOSE: Normalize Superset environment URL to base host/path without trailing slash and /api/v1 suffix. + # @PRE: raw_url can be empty. + # @POST: Returns canonical base URL suitable for building API endpoints. + # @RETURN: str + def _normalize_base_url(self, raw_url: str) -> str: + normalized = str(raw_url or "").strip().rstrip("/") + if normalized.lower().endswith("/api/v1"): + normalized = normalized[:-len("/api/v1")] + return normalized.rstrip("/") + # [/DEF:_normalize_base_url:Function] + + # [DEF:_build_api_url:Function] + # @PURPOSE: Build absolute Superset API URL for endpoint using canonical /api/v1 base. + # @PRE: endpoint is relative path or absolute URL. + # @POST: Returns full URL without accidental duplicate slashes. + # @RETURN: str + def _build_api_url(self, endpoint: str) -> str: + normalized_endpoint = str(endpoint or "").strip() + if normalized_endpoint.startswith("http://") or normalized_endpoint.startswith("https://"): + return normalized_endpoint + if not normalized_endpoint.startswith("/"): + normalized_endpoint = f"/{normalized_endpoint}" + if normalized_endpoint.startswith("/api/v1/") or normalized_endpoint == "/api/v1": + return f"{self.base_url}{normalized_endpoint}" + return f"{self.api_base_url}{normalized_endpoint}" + # [/DEF:_build_api_url:Function] + # [DEF:authenticate:Function] # @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены. # @PRE: self.auth and self.base_url must be valid. @@ -166,7 +195,7 @@ class APIClient: with belief_scope("authenticate"): app_logger.info("[authenticate][Enter] Authenticating to %s", self.base_url) try: - login_url = f"{self.base_url}/security/login" + login_url = f"{self.api_base_url}/security/login" # Log the payload keys and values (masking password) masked_auth = {k: ("******" if k == "password" else v) for k, v in self.auth.items()} app_logger.info(f"[authenticate][Debug] Login URL: {login_url}") @@ -180,7 +209,7 @@ class APIClient: response.raise_for_status() access_token = response.json()["access_token"] - csrf_url = f"{self.base_url}/security/csrf_token/" + csrf_url = f"{self.api_base_url}/security/csrf_token/" csrf_response = self.session.get(csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.request_settings["timeout"]) csrf_response.raise_for_status() @@ -224,7 +253,7 @@ class APIClient: # @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`. # @THROW: SupersetAPIError, NetworkError и их подклассы. def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]: - full_url = f"{self.base_url}{endpoint}" + full_url = self._build_api_url(endpoint) _headers = self.headers.copy() if headers: _headers.update(headers) @@ -288,7 +317,7 @@ class APIClient: # @THROW: SupersetAPIError, NetworkError, TypeError. def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict: with belief_scope("upload_file"): - full_url = f"{self.base_url}{endpoint}" + full_url = self._build_api_url(endpoint) _headers = self.headers.copy() _headers.pop('Content-Type', None) diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index 0704685..01eade6 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -791,6 +791,28 @@ class GitService: } # [/DEF:_parse_remote_repo_identity:Function] + # [DEF:_derive_server_url_from_remote:Function] + # @PURPOSE: Build API base URL from remote repository URL without credentials. + # @PRE: remote_url may be any git URL. + # @POST: Returns normalized http(s) base URL or None when derivation is impossible. + # @RETURN: Optional[str] + def _derive_server_url_from_remote(self, remote_url: str) -> Optional[str]: + normalized = str(remote_url or "").strip() + if not normalized or normalized.startswith("git@"): + return None + + parsed = urlparse(normalized) + if parsed.scheme not in {"http", "https"}: + return None + if not parsed.hostname: + return None + + netloc = parsed.hostname + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + return f"{parsed.scheme}://{netloc}".rstrip("/") + # [/DEF:_derive_server_url_from_remote:Function] + # [DEF:promote_direct_merge:Function] # @PURPOSE: Perform direct merge between branches in local repo and push target branch. # @PRE: Repository exists and both branches are valid. @@ -878,13 +900,32 @@ class GitService: "base": to_branch, "body": description or "", } - data = await self._gitea_request( - "POST", - server_url, - pat, - f"/repos/{identity['namespace']}/{identity['repo']}/pulls", - payload=payload, - ) + endpoint = f"/repos/{identity['namespace']}/{identity['repo']}/pulls" + try: + data = await self._gitea_request( + "POST", + server_url, + pat, + endpoint, + payload=payload, + ) + except HTTPException as exc: + fallback_url = self._derive_server_url_from_remote(remote_url) + normalized_primary = self._normalize_git_server_url(server_url) + if exc.status_code != 404 or not fallback_url or fallback_url == normalized_primary: + raise + + logger.warning( + "[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s", + fallback_url, + ) + data = await self._gitea_request( + "POST", + fallback_url, + pat, + endpoint, + payload=payload, + ) return { "id": data.get("number") or data.get("id"), "url": data.get("html_url") or data.get("url"), diff --git a/backend/tests/core/test_git_service_gitea_pr.py b/backend/tests/core/test_git_service_gitea_pr.py new file mode 100644 index 0000000..ebb3aad --- /dev/null +++ b/backend/tests/core/test_git_service_gitea_pr.py @@ -0,0 +1,67 @@ +# [DEF:backend.tests.core.test_git_service_gitea_pr:Module] +# @TIER: STANDARD +# @SEMANTICS: tests, git, gitea, pull_request, fallback +# @PURPOSE: Validate Gitea PR creation fallback behavior when configured server URL is stale. +# @LAYER: Domain +# @RELATION: TESTS -> backend.src.services.git_service.create_gitea_pull_request +# @INVARIANT: A 404 from primary Gitea URL retries once against remote-url host when different. + +import asyncio +import sys +from pathlib import Path + +from fastapi import HTTPException + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.services.git_service import GitService + + +# [DEF:test_derive_server_url_from_remote_strips_credentials:Function] +# @PURPOSE: Ensure helper returns host base URL and removes embedded credentials. +# @PRE: remote_url is an https URL with username/token. +# @POST: Result is scheme+host only. +def test_derive_server_url_from_remote_strips_credentials(): + service = GitService(base_path="test_repos") + derived = service._derive_server_url_from_remote( + "https://oauth2:token@giteabusya.bebesh.ru/busya/covid-vaccine-dashboard.git" + ) + assert derived == "https://giteabusya.bebesh.ru" +# [/DEF:test_derive_server_url_from_remote_strips_credentials:Function] + + +# [DEF:test_create_gitea_pull_request_retries_with_remote_host_on_404:Function] +# @PURPOSE: Verify create_gitea_pull_request retries with remote URL host after primary 404. +# @PRE: primary server_url differs from remote_url host. +# @POST: Method returns success payload from fallback request. +def test_create_gitea_pull_request_retries_with_remote_host_on_404(monkeypatch): + service = GitService(base_path="test_repos") + calls = [] + + async def fake_gitea_request(method, server_url, pat, endpoint, payload=None): + calls.append((method, server_url, endpoint)) + if len(calls) == 1: + raise HTTPException(status_code=404, detail="Gitea API error: The target couldn't be found.") + return {"number": 42, "html_url": "https://giteabusya.bebesh.ru/busya/covid-vaccine-dashboard/pulls/42", "state": "open"} + + monkeypatch.setattr(service, "_gitea_request", fake_gitea_request) + + result = asyncio.run( + service.create_gitea_pull_request( + server_url="https://gitea.bebesh.ru", + pat="secret", + remote_url="https://oauth2:secret@giteabusya.bebesh.ru/busya/covid-vaccine-dashboard.git", + from_branch="ss-dev", + to_branch="main", + title="Promote ss-dev -> main", + description="", + ) + ) + + assert result["id"] == 42 + assert len(calls) == 2 + assert calls[0][1] == "https://gitea.bebesh.ru" + assert calls[1][1] == "https://giteabusya.bebesh.ru" +# [/DEF:test_create_gitea_pull_request_retries_with_remote_host_on_404:Function] + +# [/DEF:backend.tests.core.test_git_service_gitea_pr:Module] diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..d373f37 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,453 @@ +# Установка и настройка ss-tools + +Эта документация описывает процесс установки и настройки ss-tools для локальной разработки и продакшн-среды. + +## Содержание + +- [Требования](#требования) +- [Установка через Docker](#установка-через-docker) +- [Локальная установка](#локальная-установка) +- [Первая настройка](#первая-настройка) +- [Конфигурация окружений](#конфигурация-окружений) +- [Troubleshooting](#troubleshooting) + +## Требования + +### Минимальные требования для локальной разработки + +- **Python**: 3.9 или новее +- **Node.js**: 18 или новее +- **npm**: 9 или новее +- **RAM**: 4 GB +- **Диск**: 5 GB свободного места +- **Операционная система**: Linux, macOS или WSL2 + +### Рекомендуемые требования для продакшн + +- **Docker Engine**: 24 или новее +- **Docker Compose**: v2.3 или новее +- **RAM**: 8 GB +- **Диск**: 15+ GB свободного места +- **PostgreSQL**: 16 (через Docker) + +### Дополнительные зависимости + +Для локальной разработки могут потребоваться: + +- **Git**: для клонирования репозитория +- **Curl**: для тестирования API +- **Python-разработчик**: для компиляции некоторых Python пакетов + +## Установка через Docker + +### 1. Клонирование репозитория + +```bash +git clone +cd ss-tools +``` + +### 2. Настройка переменных окружения + +Создайте файл `.env` в корневой директории: + +```bash +# Backend и Frontend порты +BACKEND_PORT=8000 +FRONTEND_PORT=5173 + +# PostgreSQL порт +POSTGRES_HOST_PORT=5432 + +# Альтернативные образы PostgreSQL (если есть проблемы с основным) +POSTGRES_IMAGE=postgres:16-alpine + +# Порты для Docker контейнеров +BACKEND_HOST_PORT=8001 +FRONTEND_HOST_PORT=8000 +``` + +### 3. Запуск контейнеров + +```bash +# Сборка и запуск всех сервисов +docker compose up --build + +# Запуск в фоне с логами +docker compose up -d + +# Мониторинг логов +docker compose logs -f +``` + +### 4. Проверка установки + +После запуска проверьте доступность сервисов: + +```bash +# Frontend +curl http://localhost:8000 + +# Backend API +curl http://localhost:8001/docs + +# PostgreSQL +docker exec ss_tools_db pg_isready -U postgres +``` + +## Локальная установка + +### 1. Backend установка + +```bash +cd backend + +# Создание виртуального окружения +python3 -m venv .venv + +# Активация виртуального окружения +source .venv/bin/activate # На Linux/macOS +# или +.venv\Scripts\activate # На Windows + +# Установка зависимостей +pip install -r requirements.txt + +# Запуск backend +python3 -m uvicorn src.app:app --reload --port 8000 +``` + +### 2. Frontend установка + +Откройте новый терминал: + +```bash +cd frontend + +# Установка зависимостей +npm install + +# Запуск dev сервера +npm run dev -- --port 5173 +``` + +### 3. Настройка PostgreSQL + +Для локальной разработки можно использовать встроенную PostgreSQL или подключиться к существующей: + +```bash +# Создание БД (если не создана через Docker) +createdb ss_tools + +# Или подключение к существующей +psql -U postgres -d ss_tools +``` + +## Первая настройка + +### 1. Инициализация базы данных + +```bash +cd backend +source .venv/bin/activate + +# Создание таблиц +python src/scripts/init_auth_db.py +``` + +### 2. Создание администратора + +```bash +python src/scripts/create_admin.py --username admin --password admin +``` + +**Важно**: После создания администратора измените пароль в продакшн-среде! + +### 3. Настройка окружений + +Перейдите в админ-панель (http://localhost:8000/settings) и добавьте свои Superset окружения: + +- **Source Environment**: окружение для миграции данных +- **Target Environment**: целевое окружение +- **Superset URL**: базовый URL Superset (например, https://superset.example.com) +- **API URL**: URL API Superset (обычно /api/v1) +- **Auth Type**: Basic Auth или OAuth2 + +### 4. Настройка Git-конфигураций + +Если планируете использовать Git-интеграцию: + +1. Перейдите в **Settings → Git** +2. Добавьте конфигурацию Git-сервера: + - **Name**: идентификатор (например, "GitHub Production") + - **Type**: GitHub, GitLab или Bitbucket + - **URL**: URL репозитория + - **Username**: имя пользователя + - **Personal Access Token**: токен с правами на репозитории + +### 5. Настройка LLM-провайдеров + +Для LLM-аналитики и документации: + +1. Перейдите в **Settings → LLM** +2. Добавьте провайдера: + - **Name**: идентификатор (например, "OpenAI Production") + - **Provider**: OpenAI, Anthropic и т.д. + - **API Key**: ключ API (будет зашифрован) + - **Model**: используемая модель (например, gpt-4-turbo) + - **Binding**: для каких задач использовать (validation, docs, commit) + +## Конфигурация окружений + +### Структура конфигурации + +Конфигурация хранится в PostgreSQL в таблице `app_configurations`: + +```sql +SELECT * FROM app_configurations WHERE key = 'global_settings'; +``` + +### Основные настройки + +```json +{ + "global_settings": { + "enable_belief_state_logging": true, + "task_log_level": "INFO", + "retention_period_days": 30, + "storage_path": "/app/storage", + "git_repos_path": "/app/backend/git_repos" + }, + "environments": [ + { + "id": "dev", + "name": "Development", + "superset_url": "http://localhost:8088", + "api_url": "/api/v1", + "auth_type": "basic", + "username": "admin", + "password": "password" + }, + { + "id": "staging", + "name": "Staging", + "superset_url": "https://staging.superset.example.com", + "api_url": "/api/v1", + "auth_type": "oauth2", + "oauth_url": "https://sso.example.com/oauth2/authorize", + "token_url": "https://sso.example.com/oauth2/token" + } + ] +} +``` + +### Переменные окружения + +Основные переменные окружения: + +```bash +# Database +DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools +TASKS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools +AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools + +# Server +BACKEND_PORT=8000 +FRONTEND_PORT=5173 + +# Security +SECRET_KEY=your-secret-key-here +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=1440 + +# LLM (опционально) +OPENAI_API_KEY=sk-... +ANTHROPIC_API_KEY=sk-ant-... + +# Git (опционально) +GIT_USERNAME=your-git-username +GIT_EMAIL=your-email@example.com +``` + +### Включение/отключение функций + +```bash +# Включение belief state логирования +export ENABLE_BELIEF_STATE_LOGGING=true + +# Уровень логирования задач +export TASK_LOG_LEVEL=DEBUG + +# Период retention (в днях) +export RETENTION_PERIOD_DAYS=90 +``` + +## Troubleshooting + +### Проблемы с Docker + +**Проблема**: Контейнеры не запускаются + +```bash +# Проверка статуса +docker compose ps + +# Просмотр логов +docker compose logs backend +docker compose logs frontend + +# Очистка и пересборка +docker compose down -v +docker compose up --build +``` + +**Проблема**: PostgreSQL порт занят + +```bash +# Изменение порта в .env +POSTGRES_HOST_PORT=5433 + +# Перезапуск +docker compose down +docker compose up -d +``` + +### Проблемы с Python + +**Проблема**: ImportError при импорте модулей + +```bash +# Проверка Python версии +python3 --version # Должен быть 3.9+ + +# Пересоздание виртуального окружения +cd backend +rm -rf .venv +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt --upgrade +``` + +**Проблема**: Нет прав на создание файлов + +```bash +# На Linux/macOS +chmod +x run.sh +chmod +x build.sh + +# На Windows +# Права обычно не требуются для .sh скриптов +``` + +### Проблемы с Node.js + +**Проблема**: npm не устанавливает зависимости + +```bash +# Очистка кэша npm +npm cache clean --force + +# Пересоздание node_modules +cd frontend +rm -rf node_modules package-lock.json +npm install +``` + +### Проблемы с БД + +**Проблема**: Подключение к PostgreSQL не удается + +```bash +# Проверка доступности БД +docker exec ss_tools_db pg_isready -U postgres + +# Проверка таблиц +docker exec ss_tools_db psql -U postgres -d ss_tools -c "\dt" + +# Ручная инициализация +docker exec -it ss_tools_db psql -U postgres -d ss_tools +``` + +**Проблема**: Сломанные миграции + +```bash +# Откат последней миграции +cd backend +source .venv/bin/activate +alembic downgrade -1 + +# Повторная инициализация +python src/scripts/init_auth_db.py +``` + +### Проблемы с Git-интеграцией + +**Проблема**: Не удается инициализировать репозиторий + +```bash +# Проверка прав на директорию +ls -la backend/git_repos/ + +# Создание директории вручную +mkdir -p backend/git_repos/ +chmod 755 backend/git_repos/ + +# Проверка Git конфигураций +git config --global user.name +git config --global user.email +``` + +**Проблема**: Нет доступа к репозиторию + +```bash +# Проверка токена +# Токен должен иметь права на: +# - Push +# - Pull +# - Create branches +# - Delete branches (если нужно) +``` + +## Проверка установки + +### Тестирование API + +```bash +# Тестирование health endpoint +curl http://localhost:8001/health + +# Тестирование списка плагинов +curl http://localhost:8001/api/plugins + +# Тестовая аутентификация +curl -X POST http://localhost:8001/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "admin"}' +``` + +### Тестирование фронтенда + +1. Откройте браузер на http://localhost:8000 +2. Проверьте, что вы можете войти с учетными данными администратора +3. Проверьте доступность основных разделов: + - Dashboard Hub + - Dataset Hub + - Settings + - Reports + +## Дополнительные ресурсы + +- [Архитектура системы](architecture.md) +- [Настройка окружений](settings.md) +- [Разработка плагинов](plugin_dev.md) +- [API документация](http://localhost:8001/docs) +- [GitHub Issues](https://github.com/yourusername/ss-tools/issues) + +## Поддержка + +Если вы столкнулись с проблемами, не описанными в этом документе: + +1. Проверьте раздел [Troubleshooting](#troubleshooting) +2. Посмотрите логи в Docker: `docker compose logs -f` +3. Откройте issue на GitHub с подробным описанием проблемы +4. Обратитесь в техническую поддержку \ No newline at end of file diff --git a/frontend/src/components/git/DeploymentModal.svelte b/frontend/src/components/git/DeploymentModal.svelte index 4a6974b..8f7888a 100644 --- a/frontend/src/components/git/DeploymentModal.svelte +++ b/frontend/src/components/git/DeploymentModal.svelte @@ -38,6 +38,13 @@ const stageMatched = all.filter((env) => normalizeEnvStage(env) === normalizedPreferredStage); return stageMatched.length > 0 ? stageMatched : all; }); + const selectedEnvironment = $derived( + deploymentCandidates.find((env) => env.id === selectedEnv) || null, + ); + const selectedEnvironmentStage = $derived( + selectedEnvironment ? normalizeEnvStage(selectedEnvironment) : "", + ); + const isProdTarget = $derived(selectedEnvironmentStage === "PROD"); // [DEF:loadStatus:Watcher] $effect(() => { @@ -107,6 +114,14 @@ */ async function handleDeploy() { if (!selectedEnv) return; + if (isProdTarget) { + const expected = String(dashboardId); + const confirmation = prompt(`Подтвердите deploy в PROD. Введите slug: ${expected}`); + if (String(confirmation || "").trim() !== expected) { + toast("Подтверждение PROD не пройдено. Deploy отменен.", "error"); + return; + } + } console.log(`[DeploymentModal][Action] Deploying to ${selectedEnv}`); deploying = true; try { @@ -171,6 +186,11 @@ {/each} + {#if isProdTarget} +

+ Внимание: выбрано PROD окружение. Потребуется подтверждение перед deploy. +

+ {/if}
- -
{$t.common?.id}: {dashboardId}
-
- -
+ + +
slug: {dashboardId}
{#if checkingStatus}
-
+
{:else if !initialized} -
+
-

- {$t.git.not_linked} +

+ {$t.git?.not_linked || 'Этот дашборд еще не привязан к Git-репозиторию.'}

- +
@@ -509,151 +593,224 @@ isLoading={loading} class="w-full" > - {$t.git.init_repo} + {$t.git?.init_repo || 'Инициализировать Git-репозиторий'}
{:else} -
- -
-
-

{$t.git.branch}

+
+
+
+ + {currentEnvStage || 'DEV'} + + Текущая ветка: {currentBranch} +
+
-
+
+
-
-

{$t.git.actions}

- - -
+
+ + + +
+ + {#if activeTab === 'workspace'} +
+
+ +
+
+

Сообщение коммита

+ +
+ +
+ +
+ Файлов с изменениями: {changedFilesCount} +
+
-
-
-

Promote

- {#if currentEnvStage} -
- Stage: {currentEnvStage} - {#if preferredDeployTargetStage} - | Next deploy target: {preferredDeployTargetStage} +
+
+ Diff (изменения) +
+
+ {#if workspaceLoading} +
+ {#each Array(6) as _} +
+ {/each} +
+ {:else if workspaceDiff} +
{workspaceDiff}
+ {:else} +
+ Нет изменений для коммита +
{/if}
- {/if} -
- -
- - {/if} + {#if preferredDeployTargetStage} +

+ Следующий шаг по GitFlow: {promoteFromBranch} ➔ {promoteToBranch} +

+ {/if} +
+ -
-
-

{$t.git.deployment}

+ + + {#if showAdvancedPromote} +
+
+ + +
+ + {/if} +
+ {/if} +
+ {:else} +
+ + - -
- - -
- -
+
+ {/if} {/if} {/if} - { /* Refresh history */ }} -/> - - - - { /* Handle resolution */ }} + bind:show={showDeployModal} /> diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index bc9ef26..e4ca703 100755 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -268,6 +268,21 @@ export const api = { if (options.search) params.append('search', options.search); if (options.page) params.append('page', options.page); if (options.page_size) params.append('page_size', options.page_size); + if (options.filters?.title) { + for (const value of options.filters.title) params.append('filter_title', value); + } + if (options.filters?.git_status) { + for (const value of options.filters.git_status) params.append('filter_git_status', value); + } + if (options.filters?.llm_status) { + for (const value of options.filters.llm_status) params.append('filter_llm_status', value); + } + if (options.filters?.changed_on) { + for (const value of options.filters.changed_on) params.append('filter_changed_on', value); + } + if (options.filters?.actor) { + for (const value of options.filters.actor) params.append('filter_actor', value); + } return fetchApi(`/dashboards?${params.toString()}`); }, getDashboardDetail: (envId, dashboardRef) => fetchApi(`/dashboards/${encodeURIComponent(String(dashboardRef))}?env_id=${envId}`), diff --git a/frontend/src/routes/dashboards/+page.svelte b/frontend/src/routes/dashboards/+page.svelte index a2fd993..64a631f 100644 --- a/frontend/src/routes/dashboards/+page.svelte +++ b/frontend/src/routes/dashboards/+page.svelte @@ -110,6 +110,10 @@ let gitResolvedIds = $state(new Set()); let gitLoadingIds = $state(new Set()); let cachedGitConfigs = $state([]); + let dashboardsLoadSeq = $state(0); + let lastLoadedEnvId = $state(null); + let serverTotal = $state(0); + let serverTotalPages = $state(1); // Dry run state let isDryRunLoading = $state(false); @@ -123,7 +127,7 @@ const debouncedSearch = debounce((query) => { searchQuery = query; currentPage = 1; - applyGridTransforms(); + void loadDashboards(); }, 300); // Load environments and dashboards on mount @@ -132,7 +136,6 @@ if (!selectedEnv && $environmentContextStore?.selectedEnvId) { selectedEnv = $environmentContextStore.selectedEnvId; } - await loadDashboards(); }); // Handle document click to close dropdowns @@ -263,30 +266,28 @@ async function loadDashboards() { if (!selectedEnv) return; + const requestSeq = dashboardsLoadSeq + 1; + dashboardsLoadSeq = requestSeq; isLoading = true; error = null; try { const firstResponse = await api.getDashboards(selectedEnv, { - page: 1, - page_size: 100, + page: currentPage, + page_size: pageSize, + search: searchQuery.trim() || undefined, + filters: { + title: Array.from(columnFilters.title), + git_status: Array.from(columnFilters.git_status), + llm_status: Array.from(columnFilters.llm_status), + changed_on: Array.from(columnFilters.changed_on), + actor: Array.from(columnFilters.actor), + }, }); + if (requestSeq !== dashboardsLoadSeq) return; - const allPages = [firstResponse]; - if (firstResponse.total_pages > 1) { - const pageRequests = []; - for (let page = 2; page <= firstResponse.total_pages; page += 1) { - pageRequests.push( - api.getDashboards(selectedEnv, { - page, - page_size: 100, - }), - ); - } - const nextPages = await Promise.all(pageRequests); - allPages.push(...nextPages); - } - - const rawDashboards = allPages.flatMap((response) => response.dashboards); + const rawDashboards = firstResponse?.dashboards || []; + serverTotal = Number(firstResponse?.total || 0); + serverTotalPages = Math.max(1, Number(firstResponse?.total_pages || 1)); gitResolvedIds = new Set(); gitLoadingIds = new Set(); @@ -337,13 +338,16 @@ applyGridTransforms(); updateSelectionState(); } catch (err) { + if (requestSeq !== dashboardsLoadSeq) return; error = err.message || $t.dashboard?.load_failed; console.error( "[DashboardHub][COHERENCE:FAILED] Load dashboards Error:", err, ); } finally { - isLoading = false; + if (requestSeq === dashboardsLoadSeq) { + isLoading = false; + } } } @@ -354,15 +358,16 @@ // Handle page change function handlePageChange(page) { + if (page === currentPage) return; currentPage = page; - applyGridTransforms(); + void loadDashboards(); } // Handle page size change function handlePageSizeChange(event) { pageSize = parseInt(event.target.value); currentPage = 1; - applyGridTransforms(); + void loadDashboards(); } // Update selection state based on current selection @@ -467,9 +472,11 @@ */ function getColumnCellValue(dashboard, column) { if (column === "title") return dashboard.title || "-"; - if (column === "git_status") return getGitSummaryLabel(dashboard); + if (column === "git_status") + return String(dashboard.git?.status || "pending").toLowerCase(); if (column === "llm_status") return getLlmSummaryLabel(dashboard); - if (column === "changed_on") return dashboard.changedOnLabel || "-"; + if (column === "changed_on") + return dashboard.changedOn ? String(dashboard.changedOn).slice(0, 10) : "-"; if (column === "actor") return dashboard.actorLabel || "-"; return "-"; } @@ -554,7 +561,7 @@ } columnFilters = { ...columnFilters, [column]: next }; currentPage = 1; - applyGridTransforms(); + void loadDashboards(); } // [/DEF:DashboardHub.toggleFilterValue:Function] @@ -568,7 +575,7 @@ function clearColumnFilter(column) { columnFilters = { ...columnFilters, [column]: new Set() }; currentPage = 1; - applyGridTransforms(); + void loadDashboards(); } // [/DEF:DashboardHub.clearColumnFilter:Function] @@ -585,7 +592,7 @@ [column]: new Set(getVisibleFilterOptions(column)), }; currentPage = 1; - applyGridTransforms(); + void loadDashboards(); } // [/DEF:DashboardHub.selectAllColumnFilterValues:Function] @@ -693,23 +700,9 @@ * @UX_STATE: Loaded -> visible rows reflect all active controls deterministically. */ function applyGridTransforms() { - const searchText = searchQuery.trim().toLowerCase(); const nextFiltered = allDashboards - .filter((dashboard) => { - if (!searchText) return true; - const haystack = [ - dashboard.title, - dashboard.actorLabel, - getGitSummaryLabel(dashboard), - getLlmSummaryLabel(dashboard), - dashboard.changedOnLabel, - ] - .filter((value) => Boolean(value)) - .join(" ") - .toLowerCase(); - return haystack.includes(searchText); - }) - .filter((dashboard) => doesDashboardPassColumnFilters(dashboard)); + // Search and column filters are server-side. + .filter(() => true); nextFiltered.sort((a, b) => { const aValue = getSortValue(a, sortColumn); @@ -720,16 +713,11 @@ }); filteredDashboards = nextFiltered; - total = filteredDashboards.length; - totalPages = Math.max(1, Math.ceil(total / pageSize)); - if (currentPage > totalPages) { - currentPage = totalPages; - } - const start = (currentPage - 1) * pageSize; - const end = start + pageSize; - dashboards = filteredDashboards.slice(start, end); + dashboards = filteredDashboards; + total = serverTotal; + totalPages = serverTotalPages; updateSelectionState(); - void hydrateVisibleGitStatuses(); + void hydrateVisibleGitStatusesBatch(); } // [/DEF:DashboardHub.applyGridTransforms:Function] @@ -1131,40 +1119,67 @@ }; } - async function refreshDashboardGitState(dashboardId, force = false) { - if (gitLoadingIds.has(dashboardId)) { - return; - } - if (force && gitResolvedIds.has(dashboardId)) { - gitResolvedIds.delete(dashboardId); + async function fetchDashboardGitStatusesBatch(dashboardIds, force = false) { + const uniqueIds = Array.from(new Set(dashboardIds || [])); + if (uniqueIds.length === 0) return; + + if (force) { + uniqueIds.forEach((dashboardId) => gitResolvedIds.delete(dashboardId)); gitResolvedIds = new Set(gitResolvedIds); } - if (!force && gitResolvedIds.has(dashboardId)) return; - gitLoadingIds.add(dashboardId); + const pendingIds = uniqueIds.filter( + (dashboardId) => + !gitResolvedIds.has(dashboardId) && !gitLoadingIds.has(dashboardId), + ); + if (pendingIds.length === 0) return; + + pendingIds.forEach((dashboardId) => gitLoadingIds.add(dashboardId)); gitLoadingIds = new Set(gitLoadingIds); + try { - const status = await gitService.getStatus(dashboardId); - updateDashboardGitState( - dashboardId, - normalizeRepositoryStatusPayload(status), - ); + const batchResult = await gitService.getStatusesBatch(pendingIds); + const statusesByDashboardId = batchResult?.statuses || {}; + pendingIds.forEach((dashboardId) => { + const status = + statusesByDashboardId[dashboardId] ?? + statusesByDashboardId[String(dashboardId)]; + updateDashboardGitState( + dashboardId, + status + ? normalizeRepositoryStatusPayload(status) + : { + status: "no_repo", + branch: null, + hasRepo: false, + hasChangesForCommit: false, + }, + ); + }); } catch (_err) { - updateDashboardGitState(dashboardId, { - status: "no_repo", - branch: null, - hasRepo: false, - hasChangesForCommit: false, + pendingIds.forEach((dashboardId) => { + updateDashboardGitState(dashboardId, { + status: "no_repo", + branch: null, + hasRepo: false, + hasChangesForCommit: false, + }); }); } finally { - gitLoadingIds.delete(dashboardId); + pendingIds.forEach((dashboardId) => { + gitLoadingIds.delete(dashboardId); + gitResolvedIds.add(dashboardId); + }); gitLoadingIds = new Set(gitLoadingIds); - gitResolvedIds.add(dashboardId); gitResolvedIds = new Set(gitResolvedIds); } } - async function hydrateVisibleGitStatuses() { + async function refreshDashboardGitState(dashboardId, force = false) { + await fetchDashboardGitStatusesBatch([dashboardId], force); + } + + async function hydrateVisibleGitStatusesBatch() { const pendingIds = dashboards .filter( (dashboard) => @@ -1174,10 +1189,7 @@ ) .map((dashboard) => dashboard.id); if (pendingIds.length === 0) return; - - await Promise.all( - pendingIds.map((dashboardId) => refreshDashboardGitState(dashboardId)), - ); + await fetchDashboardGitStatusesBatch(pendingIds, false); } async function handleGitInit(dashboard) { @@ -1349,17 +1361,21 @@ } $effect(() => { - if ( - $environmentContextStore?.selectedEnvId && - selectedEnv !== $environmentContextStore.selectedEnvId - ) { - selectedEnv = $environmentContextStore.selectedEnvId; - currentPage = 1; - selectedIds.clear(); - selectedIds = selectedIds; - loadDashboards(); + const contextEnvId = $environmentContextStore?.selectedEnvId; + if (contextEnvId && selectedEnv !== contextEnvId) { + selectedEnv = contextEnvId; } }); + + $effect(() => { + const envId = selectedEnv; + if (!envId || envId === lastLoadedEnvId) return; + lastLoadedEnvId = envId; + currentPage = 1; + selectedIds.clear(); + selectedIds = selectedIds; + void loadDashboards(); + });
diff --git a/frontend/src/routes/dashboards/[id]/+page.svelte b/frontend/src/routes/dashboards/[id]/+page.svelte index 6d98c56..ecfeecd 100644 --- a/frontend/src/routes/dashboards/[id]/+page.svelte +++ b/frontend/src/routes/dashboards/[id]/+page.svelte @@ -32,7 +32,6 @@ import { addToast } from "$lib/toasts.js"; import Icon from "$lib/ui/Icon.svelte"; import BranchSelector from "../../../components/git/BranchSelector.svelte"; - import CommitModal from "../../../components/git/CommitModal.svelte"; import CommitHistory from "../../../components/git/CommitHistory.svelte"; import GitManager from "../../../components/git/GitManager.svelte"; @@ -66,7 +65,6 @@ let isPushingGit = false; let currentBranch = "main"; let activeTab = "resources"; - let showCommitModal = false; let showGitManager = false; let gitMeta = getGitStatusMeta(); @@ -308,7 +306,32 @@ function hasGitRepository() { if (!gitStatus) return false; if (gitStatus.has_repo === false) return false; - return gitStatus.sync_state !== "NO_REPO" && Boolean(gitStatus.current_branch); + return gitStatus.sync_state !== "NO_REPO" || Boolean(gitStatus.current_branch); + } + + function resolveGitSyncState() { + if (!gitStatus || gitStatus.has_repo === false) return "NO_REPO"; + const explicitState = String(gitStatus.sync_state || "").toUpperCase(); + + const changedCount = + (gitStatus?.modified_files?.length || 0) + + (gitStatus?.staged_files?.length || 0) + + (gitStatus?.untracked_files?.length || 0); + const ahead = Number(gitStatus?.ahead_count || 0); + const behind = Number(gitStatus?.behind_count || 0); + if ( + explicitState && + explicitState !== "UNKNOWN" && + explicitState !== "NO_REPO" + ) { + return explicitState; + } + if (changedCount > 0) return "CHANGES"; + if (ahead > 0 && behind > 0) return "DIVERGED"; + if (ahead > 0) return "AHEAD_REMOTE"; + if (behind > 0) return "BEHIND_REMOTE"; + if (gitStatus?.current_branch) return "SYNCED"; + return "NO_REPO"; } function allChangedFiles() { @@ -326,7 +349,7 @@ } function getGitStatusMeta() { - const syncState = gitStatus?.sync_state || "NO_REPO"; + const syncState = resolveGitSyncState(); if (syncState === "SYNCED") { return { dotClass: "bg-emerald-500", @@ -429,7 +452,7 @@ try { await gitService.sync(gitDashboardRef, envId || null, envId || null); addToast($t.git?.sync_success || "Dashboard state synced to Git", "success"); - showCommitModal = true; + showGitManager = true; await loadGitStatus(); } catch (err) { addToast(err.message || "Git sync failed", "error"); @@ -474,6 +497,10 @@ } $: gitMeta = getGitStatusMeta(); + $: gitSyncState = resolveGitSyncState(); + $: changedChartsCount = countChangedByAnyPath(["/charts/", "charts/"]); + $: changedDatasetsCount = countChangedByAnyPath(["/datasets/", "datasets/"]); + $: hasChangesToCommit = allChangedFiles().length > 0;
@@ -665,25 +692,33 @@ {gitStatusError}
{:else if !hasGitRepository()} -
- {$t.git?.not_linked || "This dashboard is not yet linked to a Git repository."} +
+
+ {$t.git?.not_linked || "Этот дашборд еще не привязан к Git-репозиторию."} +
+
{:else}

- {#if gitStatus?.sync_state === "CHANGES"} - Superset configuration changes were detected. + {#if gitSyncState === "CHANGES"} + Обнаружены изменения конфигурации в Superset. {:else} - Superset configuration matches branch {gitStatus?.current_branch || "main"}. + Конфигурация Superset совпадает с веткой {gitStatus?.current_branch || "main"}. {/if}

- charts: {countChangedByAnyPath(["/charts/", "charts/"])} + charts: {changedChartsCount} - datasets: {countChangedByAnyPath(["/datasets/", "datasets/"])} + datasets: {changedDatasetsCount} files: {allChangedFiles().length} @@ -694,18 +729,18 @@
@@ -992,16 +1027,7 @@ {/if}
-{#if gitDashboardRef} - -{/if} - -{#if gitDashboardRef} +{#if showGitManager && gitDashboardRef}