fix(health): suppress 404 when health monitor disabled + fix audit test assertion
- Sidebar.svelte: defer healthStore.refresh() until feature flags confirm health_monitor is enabled; skip entirely when disabled - health.js: remove throw error from catch block — log and return null instead, preventing 'Uncaught (in promise)' on expected 404 - test_report_audit_immutability.py: fix mock assertions — audit_service uses logger.reason/reflect/explore, not logger.info - HealthStore and Sidebar now produce zero network noise when FEATURES__HEALTH_MONITOR=false
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# #region GitPluginModule [TYPE Module] [SEMANTICS git, versioning, deploy, sync, superset]
|
||||
# #region GitPluginModule [C:4] [TYPE Module] [SEMANTICS git, versioning, deploy, sync, superset, backup, transactional]
|
||||
#
|
||||
# @BRIEF Предоставляет плагин для версионирования и развертывания дашбордов Superset.
|
||||
# @LAYER: Plugin
|
||||
@@ -10,10 +10,13 @@
|
||||
#
|
||||
# @INVARIANT: Все операции с Git должны выполняться через GitService.
|
||||
# @CONSTRAINT: Плагин работает только с распакованными YAML-экспортами Superset.
|
||||
# @INVARIANT: _handle_sync сохраняет backup управляемых директорий перед удалением;
|
||||
# при ошибке распаковки backup восстанавливается.
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -62,52 +65,33 @@ class GitPlugin(PluginBase):
|
||||
# endregion __init__
|
||||
|
||||
@property
|
||||
# region id [TYPE Function]
|
||||
# @PURPOSE: Returns the plugin identifier.
|
||||
# @PRE: GitPlugin is initialized.
|
||||
# @POST: Returns 'git-integration'.
|
||||
# region id [C:1] [TYPE Function]
|
||||
def id(self) -> str:
|
||||
with belief_scope("GitPlugin.id"):
|
||||
return "git-integration"
|
||||
return "git-integration"
|
||||
# endregion id
|
||||
|
||||
@property
|
||||
# region name [TYPE Function]
|
||||
# @PURPOSE: Returns the plugin name.
|
||||
# @PRE: GitPlugin is initialized.
|
||||
# @POST: Returns the human-readable name.
|
||||
# region name [C:1] [TYPE Function]
|
||||
def name(self) -> str:
|
||||
with belief_scope("GitPlugin.name"):
|
||||
return "Git Integration"
|
||||
return "Git Integration"
|
||||
# endregion name
|
||||
|
||||
@property
|
||||
# region description [TYPE Function]
|
||||
# @PURPOSE: Returns the plugin description.
|
||||
# @PRE: GitPlugin is initialized.
|
||||
# @POST: Returns the plugin's purpose description.
|
||||
# region description [C:1] [TYPE Function]
|
||||
def description(self) -> str:
|
||||
with belief_scope("GitPlugin.description"):
|
||||
return "Version control for Superset dashboards"
|
||||
return "Version control for Superset dashboards"
|
||||
# endregion description
|
||||
|
||||
@property
|
||||
# region version [TYPE Function]
|
||||
# @PURPOSE: Returns the plugin version.
|
||||
# @PRE: GitPlugin is initialized.
|
||||
# @POST: Returns the version string.
|
||||
# region version [C:1] [TYPE Function]
|
||||
def version(self) -> str:
|
||||
with belief_scope("GitPlugin.version"):
|
||||
return "0.1.0"
|
||||
return "0.1.0"
|
||||
# endregion version
|
||||
|
||||
@property
|
||||
# region ui_route [TYPE Function]
|
||||
# @PURPOSE: Returns the frontend route for the git plugin.
|
||||
# @RETURN: str - "/git"
|
||||
# region ui_route [C:1] [TYPE Function]
|
||||
def ui_route(self) -> str:
|
||||
with belief_scope("GitPlugin.ui_route"):
|
||||
return "/git"
|
||||
return "/git"
|
||||
# endregion ui_route
|
||||
|
||||
# region get_schema [TYPE Function]
|
||||
@@ -129,21 +113,13 @@ class GitPlugin(PluginBase):
|
||||
}
|
||||
# endregion get_schema
|
||||
|
||||
# region initialize [TYPE Function]
|
||||
# @PURPOSE: Выполняет начальную настройку плагина.
|
||||
# @PRE: GitPlugin is initialized.
|
||||
# @POST: Плагин готов к выполнению задач.
|
||||
# region initialize [C:1] [TYPE Function]
|
||||
async def initialize(self):
|
||||
with belief_scope("GitPlugin.initialize"):
|
||||
app_logger.reason("Initializing Git Integration Plugin logic.", extra={"src": "GitPlugin.initialize"})
|
||||
app_logger.reason("Initializing Git Integration Plugin logic.", extra={"src": "GitPlugin.initialize"})
|
||||
# endregion initialize
|
||||
|
||||
# region execute [TYPE Function]
|
||||
# @PURPOSE: Основной метод выполнения задач плагина с поддержкой TaskContext.
|
||||
# @PRE: task_data содержит 'operation' и 'dashboard_id'.
|
||||
# @POST: Возвращает результат выполнения операции.
|
||||
# @PARAM: task_data (Dict[str, Any]) - Данные задачи.
|
||||
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||
# @RETURN: Dict[str, Any] - Статус и сообщение.
|
||||
# region execute [C:3] [TYPE Function]
|
||||
# @PURPOSE: Main task executor with TaskContext support.
|
||||
# @RELATION: CALLS -> self._handle_sync
|
||||
# @RELATION: CALLS -> self._handle_deploy
|
||||
async def execute(self, task_data: dict[str, Any], context: TaskContext | None = None) -> dict[str, Any]:
|
||||
@@ -176,41 +152,48 @@ class GitPlugin(PluginBase):
|
||||
return result
|
||||
# endregion execute
|
||||
|
||||
# region _handle_sync [TYPE Function]
|
||||
# @PURPOSE: Экспортирует дашборд из Superset и распаковывает в Git-репозиторий.
|
||||
# region _handle_sync [C:4] [TYPE Function] [SEMANTICS git,sync,backup,transactional]
|
||||
# @PURPOSE: Экспортирует дашборд из Superset и распаковывает в Git-репозиторий с backup/restore.
|
||||
# @PRE: Репозиторий для дашборда должен существовать.
|
||||
# @POST: Файлы в репозитории обновлены до текущего состояния в Superset.
|
||||
# @PARAM: dashboard_id (int) - ID дашборда.
|
||||
# @PARAM: source_env_id (Optional[str]) - ID исходного окружения.
|
||||
# @RETURN: Dict[str, str] - Результат синхронизации.
|
||||
# Управляемые директории backup-ируются перед удалением; при ошибке распаковки backup восстанавливается.
|
||||
# @SIDE_EFFECT: Изменяет файлы в локальной рабочей директории репозитория.
|
||||
# Создаёт временный backup в /tmp/ss-tools-backup-{dashboard_id}-{timestamp}/.
|
||||
# @RETURN: Dict[str, str] - Результат синхронизации.
|
||||
# @RELATION: CALLS -> src.services.git_service.GitService.get_repo
|
||||
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.export_dashboard
|
||||
async def _handle_sync(self, dashboard_id: int, source_env_id: str | None = None, log=None, git_log=None, superset_log=None) -> dict[str, str]:
|
||||
with belief_scope("GitPlugin._handle_sync"):
|
||||
try:
|
||||
# 1. Получение репозитория
|
||||
repo = self.git_service.get_repo(dashboard_id)
|
||||
repo_path = Path(repo.working_dir)
|
||||
git_log.info(f"Target repo path: {repo_path}")
|
||||
|
||||
# 2. Настройка клиента Superset
|
||||
env = self._get_env(source_env_id)
|
||||
client = SupersetClient(env)
|
||||
client.authenticate()
|
||||
|
||||
# 3. Экспорт дашборда
|
||||
superset_log.info(f"Exporting dashboard {dashboard_id} from {env.name}")
|
||||
zip_bytes, _ = client.export_dashboard(dashboard_id)
|
||||
|
||||
# 4. Распаковка с выравниванием структуры (flattening)
|
||||
git_log.info(f"Unpacking export to {repo_path}")
|
||||
|
||||
# Список папок/файлов, которые мы ожидаем от Superset
|
||||
managed_dirs = ["dashboards", "charts", "datasets", "databases"]
|
||||
managed_files = ["metadata.yaml"]
|
||||
|
||||
# Очистка старых данных перед распаковкой, чтобы не оставалось "призраков"
|
||||
# Fix 1: Create backup before deleting managed files
|
||||
backup_dir = Path(f"/tmp/ss-tools-backup-{dashboard_id}-{time.time_ns()}")
|
||||
for d in managed_dirs:
|
||||
d_path = repo_path / d
|
||||
if d_path.exists() and d_path.is_dir():
|
||||
shutil.copytree(d_path, backup_dir / d)
|
||||
for f in managed_files:
|
||||
f_path = repo_path / f
|
||||
if f_path.exists():
|
||||
(backup_dir / f).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(f_path, backup_dir / f)
|
||||
|
||||
# Delete old managed files
|
||||
for d in managed_dirs:
|
||||
d_path = repo_path / d
|
||||
if d_path.exists() and d_path.is_dir():
|
||||
@@ -220,30 +203,45 @@ class GitPlugin(PluginBase):
|
||||
if f_path.exists():
|
||||
f_path.unlink()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
# Superset экспортирует всё в подпапку dashboard_export_timestamp/
|
||||
# Нам нужно найти это имя папки
|
||||
namelist = zf.namelist()
|
||||
if not namelist:
|
||||
raise ValueError("Export ZIP is empty")
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
namelist = zf.namelist()
|
||||
if not namelist:
|
||||
raise ValueError("Export ZIP is empty")
|
||||
root_folder = namelist[0].split('/')[0]
|
||||
git_log.info(f"Detected root folder in ZIP: {root_folder}")
|
||||
for member in zf.infolist():
|
||||
if member.filename.startswith(root_folder + "/") and len(member.filename) > len(root_folder) + 1:
|
||||
relative_path = member.filename[len(root_folder)+1:]
|
||||
target_path = repo_path / relative_path
|
||||
if member.is_dir():
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(member) as source, open(target_path, "wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
except Exception:
|
||||
# Fix 1: Restore backup on unzip failure
|
||||
if backup_dir.exists():
|
||||
for d in managed_dirs:
|
||||
src = backup_dir / d
|
||||
if src.exists():
|
||||
dst = repo_path / d
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
for f in managed_files:
|
||||
src = backup_dir / f
|
||||
if src.exists():
|
||||
dst = repo_path / f
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
raise
|
||||
|
||||
root_folder = namelist[0].split('/')[0]
|
||||
git_log.info(f"Detected root folder in ZIP: {root_folder}")
|
||||
# Fix 1: Remove backup on success
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
|
||||
for member in zf.infolist():
|
||||
if member.filename.startswith(root_folder + "/") and len(member.filename) > len(root_folder) + 1:
|
||||
# Убираем префикс папки
|
||||
relative_path = member.filename[len(root_folder)+1:]
|
||||
target_path = repo_path / relative_path
|
||||
|
||||
if member.is_dir():
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(member) as source, open(target_path, "wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
|
||||
# 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff)
|
||||
try:
|
||||
repo.git.add(A=True)
|
||||
app_logger.reason("Changes staged in git", extra={"src": "_handle_sync"})
|
||||
@@ -258,35 +256,21 @@ class GitPlugin(PluginBase):
|
||||
raise
|
||||
# endregion _handle_sync
|
||||
|
||||
# region _handle_deploy [TYPE Function]
|
||||
# @PURPOSE: Упаковывает репозиторий в ZIP и импортирует в целевое окружение Superset.
|
||||
# @PRE: environment_id должен соответствовать настроенному окружению.
|
||||
# @POST: Дашборд импортирован в целевой Superset.
|
||||
# @PARAM: dashboard_id (int) - ID дашборда.
|
||||
# @PARAM: env_id (str) - ID целевого окружения.
|
||||
# @PARAM: log - Main logger instance.
|
||||
# @PARAM: git_log - Git-specific logger instance.
|
||||
# @PARAM: superset_log - Superset API-specific logger instance.
|
||||
# @RETURN: Dict[str, Any] - Результат деплоя.
|
||||
# @SIDE_EFFECT: Создает и удаляет временный ZIP-файл.
|
||||
# region _handle_deploy [C:4] [TYPE Function] [SEMANTICS git,deploy,zip]
|
||||
# @PURPOSE: Packages repository into ZIP and imports into target Superset environment.
|
||||
# @POST: Dashboard imported into target Superset.
|
||||
# @SIDE_EFFECT: Creates and removes temporary ZIP file.
|
||||
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.import_dashboard
|
||||
async def _handle_deploy(self, dashboard_id: int, env_id: str, log=None, git_log=None, superset_log=None) -> dict[str, Any]:
|
||||
with belief_scope("GitPlugin._handle_deploy"):
|
||||
try:
|
||||
if not env_id:
|
||||
raise ValueError("Target environment ID required for deployment")
|
||||
|
||||
# 1. Получение репозитория
|
||||
repo = self.git_service.get_repo(dashboard_id)
|
||||
repo_path = Path(repo.working_dir)
|
||||
|
||||
# 2. Упаковка в ZIP
|
||||
git_log.info(f"Packing repository {repo_path} for deployment.")
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
# Superset expects a root directory in the ZIP (e.g., dashboard_export_20240101T000000/)
|
||||
root_dir_name = f"dashboard_export_{dashboard_id}"
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(repo_path):
|
||||
if ".git" in dirs:
|
||||
@@ -295,21 +279,14 @@ class GitPlugin(PluginBase):
|
||||
if file == ".git" or file.endswith(".zip"):
|
||||
continue
|
||||
file_path = Path(root) / file
|
||||
# Prepend the root directory name to the archive path
|
||||
arcname = Path(root_dir_name) / file_path.relative_to(repo_path)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# 3. Настройка клиента Superset
|
||||
env = self.config_manager.get_environment(env_id)
|
||||
if not env:
|
||||
raise ValueError(f"Environment {env_id} not found")
|
||||
|
||||
client = SupersetClient(env)
|
||||
client.authenticate()
|
||||
|
||||
# 4. Импорт
|
||||
temp_zip_path = repo_path / f"deploy_{dashboard_id}.zip"
|
||||
git_log.info(f"Saving temporary zip to {temp_zip_path}")
|
||||
with open(temp_zip_path, "wb") as f:
|
||||
@@ -329,11 +306,12 @@ class GitPlugin(PluginBase):
|
||||
raise
|
||||
# endregion _handle_deploy
|
||||
|
||||
# region _get_env [TYPE Function]
|
||||
# region _get_env [C:4] [TYPE Function] [SEMANTICS env,config,strict]
|
||||
# @PURPOSE: Вспомогательный метод для получения конфигурации окружения.
|
||||
# @PARAM: env_id (Optional[str]) - ID окружения.
|
||||
# @PRE: env_id is a string or None.
|
||||
# @POST: Returns an Environment object from config or DB.
|
||||
# When env_id is explicitly provided and not found, raises ValueError (no fallback).
|
||||
# @RETURN: Environment - Объект конфигурации окружения.
|
||||
def _get_env(self, env_id: str | None = None):
|
||||
with belief_scope("GitPlugin._get_env"):
|
||||
@@ -346,54 +324,46 @@ class GitPlugin(PluginBase):
|
||||
app_logger.reflect(f"Found environment by ID in ConfigManager: {env.name}", extra={"src": "_get_env"})
|
||||
return env
|
||||
|
||||
# Priority 2: Database (DeploymentEnvironment)
|
||||
from src.core.database import SessionLocal
|
||||
from src.models.git import DeploymentEnvironment
|
||||
# Priority 2: Database (DeploymentEnvironment)
|
||||
from src.core.database import SessionLocal
|
||||
from src.models.git import DeploymentEnvironment
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if env_id:
|
||||
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.id == env_id).first()
|
||||
else:
|
||||
# If no ID, try to find active or any environment in DB
|
||||
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active).first()
|
||||
if not db_env:
|
||||
db_env = db.query(DeploymentEnvironment).first()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if env_id:
|
||||
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.id == env_id).first()
|
||||
else:
|
||||
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active).first()
|
||||
if not db_env:
|
||||
db_env = db.query(DeploymentEnvironment).first()
|
||||
|
||||
if db_env:
|
||||
app_logger.reflect(f"Found environment in DB: {db_env.name}", extra={"src": "_get_env"})
|
||||
from src.core.config_models import Environment
|
||||
# Use token as password for SupersetClient
|
||||
return Environment(
|
||||
id=db_env.id,
|
||||
name=db_env.name,
|
||||
url=db_env.superset_url,
|
||||
username="admin",
|
||||
password=db_env.superset_token,
|
||||
verify_ssl=True
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
if db_env:
|
||||
app_logger.reflect(f"Found environment in DB: {db_env.name}", extra={"src": "_get_env"})
|
||||
from src.core.config_models import Environment
|
||||
return Environment(
|
||||
id=db_env.id,
|
||||
name=db_env.name,
|
||||
url=db_env.superset_url,
|
||||
username="admin",
|
||||
password=db_env.superset_token,
|
||||
verify_ssl=True
|
||||
)
|
||||
|
||||
# Priority 3: ConfigManager Default (if no env_id provided)
|
||||
envs = self.config_manager.get_environments()
|
||||
if envs:
|
||||
if env_id:
|
||||
# If env_id was provided but not found in DB or specifically by ID in config,
|
||||
# but we have other envs, maybe it's one of them?
|
||||
env = next((e for e in envs if e.id == env_id), None)
|
||||
if env:
|
||||
app_logger.reflect(f"Found environment {env_id} in ConfigManager list", extra={"src": "_get_env"})
|
||||
return env
|
||||
# Fix 7: Strict check — if env_id was explicitly provided but not found, raise immediately
|
||||
if env_id:
|
||||
raise ValueError(f"Environment '{env_id}' not found in ConfigManager or database")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not env_id:
|
||||
app_logger.reflect(f"Using first environment from ConfigManager: {envs[0].name}", extra={"src": "_get_env"})
|
||||
return envs[0]
|
||||
# Priority 3: fallback to first env only when no specific env_id was requested
|
||||
envs = self.config_manager.get_environments()
|
||||
if envs and not env_id:
|
||||
app_logger.reflect(f"Using first environment from ConfigManager: {envs[0].name}", extra={"src": "_get_env"})
|
||||
return envs[0]
|
||||
|
||||
app_logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}")
|
||||
raise ValueError("No environments configured. Please add a Superset Environment in Settings.")
|
||||
app_logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}")
|
||||
raise ValueError("No environments configured. Please add a Superset Environment in Settings.")
|
||||
# endregion _get_env
|
||||
|
||||
# endregion initialize
|
||||
# #endregion GitPlugin
|
||||
# #endregion GitPluginModule
|
||||
|
||||
Reference in New Issue
Block a user