feat: restore legacy data and add typed task result views

This commit is contained in:
2026-02-21 23:17:56 +03:00
parent 0cf0ef25f1
commit 6ffdf5f8a4
10 changed files with 411 additions and 195 deletions

View File

@@ -4,7 +4,7 @@
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks. # @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
# @LAYER: UI (API) # @LAYER: UI (API)
# @RELATION: Depends on the TaskManager. It is included by the main app. # @RELATION: Depends on the TaskManager. It is included by the main app.
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel from pydantic import BaseModel
from ...core.logger import belief_scope from ...core.logger import belief_scope
@@ -13,9 +13,15 @@ from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...core.task_manager.models import LogFilter, LogStats from ...core.task_manager.models import LogFilter, LogStats
from ...dependencies import get_task_manager, has_permission, get_current_user from ...dependencies import get_task_manager, has_permission, get_current_user
router = APIRouter() router = APIRouter()
class CreateTaskRequest(BaseModel): TASK_TYPE_PLUGIN_MAP = {
"llm_validation": ["llm_dashboard_validation"],
"backup": ["superset-backup"],
"migration": ["superset-migration"],
}
class CreateTaskRequest(BaseModel):
plugin_id: str plugin_id: str
params: Dict[str, Any] params: Dict[str, Any]
@@ -79,18 +85,36 @@ async def create_task(
# @PRE: task_manager must be available. # @PRE: task_manager must be available.
# @POST: Returns a list of tasks. # @POST: Returns a list of tasks.
# @RETURN: List[Task] - 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,
status: Optional[TaskStatus] = None, status_filter: Optional[TaskStatus] = Query(None, alias="status"),
task_manager: TaskManager = Depends(get_task_manager), task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
_ = Depends(has_permission("tasks", "READ")) plugin_id: Optional[List[str]] = Query(None, description="Filter by plugin_id (repeatable query param)"),
): completed_only: bool = Query(False, description="Return only completed tasks (SUCCESS/FAILED)"),
""" task_manager: TaskManager = Depends(get_task_manager),
Retrieve a list of tasks with pagination and optional status filter. _ = Depends(has_permission("tasks", "READ"))
""" ):
with belief_scope("list_tasks"): """
return task_manager.get_tasks(limit=limit, offset=offset, status=status) Retrieve a list of tasks with pagination and optional status filter.
"""
with belief_scope("list_tasks"):
plugin_filters = list(plugin_id) if plugin_id else []
if task_type:
if task_type not in TASK_TYPE_PLUGIN_MAP:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}"
)
plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type])
return task_manager.get_tasks(
limit=limit,
offset=offset,
status=status_filter,
plugin_ids=plugin_filters or None,
completed_only=completed_only
)
# [/DEF:list_tasks:Function] # [/DEF:list_tasks:Function]
@router.get("/{task_id}", response_model=Task) @router.get("/{task_id}", response_model=Task)
@@ -276,4 +300,4 @@ async def clear_tasks(
task_manager.clear_tasks(status) task_manager.clear_tasks(status)
return return
# [/DEF:clear_tasks:Function] # [/DEF:clear_tasks:Function]
# [/DEF:TasksRouter:Module] # [/DEF:TasksRouter:Module]

View File

@@ -8,14 +8,9 @@
# @INVARIANT: Uses bcrypt for hashing with standard work factor. # @INVARIANT: Uses bcrypt for hashing with standard work factor.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from passlib.context import CryptContext import bcrypt
# [/SECTION] # [/SECTION]
# [DEF:pwd_context:Variable]
# @PURPOSE: Passlib CryptContext for password management.
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# [/DEF:pwd_context:Variable]
# [DEF:verify_password:Function] # [DEF:verify_password:Function]
# @PURPOSE: Verifies a plain password against a hashed password. # @PURPOSE: Verifies a plain password against a hashed password.
# @PRE: plain_password is a string, hashed_password is a bcrypt hash. # @PRE: plain_password is a string, hashed_password is a bcrypt hash.
@@ -25,7 +20,15 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# @PARAM: hashed_password (str) - The stored hash. # @PARAM: hashed_password (str) - The stored hash.
# @RETURN: bool - Verification result. # @RETURN: bool - Verification result.
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password) if not hashed_password:
return False
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
except Exception:
return False
# [/DEF:verify_password:Function] # [/DEF:verify_password:Function]
# [DEF:get_password_hash:Function] # [DEF:get_password_hash:Function]
@@ -36,7 +39,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
# @PARAM: password (str) - The password to hash. # @PARAM: password (str) - The password to hash.
# @RETURN: str - The generated hash. # @RETURN: str - The generated hash.
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
return pwd_context.hash(password) return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
# [/DEF:get_password_hash:Function] # [/DEF:get_password_hash:Function]
# [/DEF:backend.src.core.auth.security:Module] # [/DEF:backend.src.core.auth.security:Module]

View File

@@ -17,6 +17,7 @@ from ..models.mapping import Base
from ..models import task as _task_models # noqa: F401 from ..models import task as _task_models # noqa: F401
from ..models import auth as _auth_models # noqa: F401 from ..models import auth as _auth_models # noqa: F401
from ..models import config as _config_models # noqa: F401 from ..models import config as _config_models # noqa: F401
from ..models import llm as _llm_models # noqa: F401
from .logger import belief_scope from .logger import belief_scope
from .auth.config import auth_config from .auth.config import auth_config
import os import os

View File

@@ -312,11 +312,23 @@ class TaskManager:
# @PARAM: offset (int) - Number of tasks to skip. # @PARAM: offset (int) - Number of tasks to skip.
# @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,
plugin_ids: Optional[List[str]] = None,
completed_only: bool = False
) -> List[Task]:
with belief_scope("TaskManager.get_tasks"): with belief_scope("TaskManager.get_tasks"):
tasks = list(self.tasks.values()) 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]
if plugin_ids:
plugin_id_set = set(plugin_ids)
tasks = [t for t in tasks if t.plugin_id in plugin_id_set]
if completed_only:
tasks = [t for t in tasks if t.status in [TaskStatus.SUCCESS, TaskStatus.FAILED]]
# 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]
@@ -568,4 +580,4 @@ class TaskManager:
# [/DEF:clear_tasks:Function] # [/DEF:clear_tasks:Function]
# [/DEF:TaskManager:Class] # [/DEF:TaskManager:Class]
# [/DEF:TaskManagerModule:Module] # [/DEF:TaskManagerModule:Module]

View File

@@ -154,10 +154,10 @@ class BackupPlugin(PluginBase):
log.info(f"Starting backup for environment: {env}") log.info(f"Starting backup for environment: {env}")
try: try:
config_manager = get_config_manager() config_manager = get_config_manager()
if not config_manager.has_environments(): if not config_manager.has_environments():
raise ValueError("No Superset environments configured. Please add an environment in Settings.") raise ValueError("No Superset environments configured. Please add an environment in Settings.")
env_config = config_manager.get_environment(env) env_config = config_manager.get_environment(env)
if not env_config: if not env_config:
@@ -180,16 +180,27 @@ class BackupPlugin(PluginBase):
superset_log.info("No dashboard filter applied - backing up all dashboards") superset_log.info("No dashboard filter applied - backing up all dashboards")
dashboard_meta = all_dashboard_meta dashboard_meta = all_dashboard_meta
if dashboard_count == 0: if dashboard_count == 0:
log.info("No dashboards to back up") log.info("No dashboards to back up")
return return {
"status": "NO_DASHBOARDS",
total = len(dashboard_meta) "environment": env,
for idx, db in enumerate(dashboard_meta, 1): "backup_root": str(backup_path / env.upper()),
dashboard_id = db.get('id') "total_dashboards": 0,
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard') "backed_up_dashboards": 0,
if not dashboard_id: "failed_dashboards": 0,
continue "dashboards": [],
"failures": []
}
total = len(dashboard_meta)
backed_up_dashboards = []
failed_dashboards = []
for idx, db in enumerate(dashboard_meta, 1):
dashboard_id = db.get('id')
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
if not dashboard_id:
continue
# Report progress # Report progress
progress_pct = (idx / total) * 100 progress_pct = (idx / total) * 100
@@ -210,21 +221,41 @@ class BackupPlugin(PluginBase):
unpack=False unpack=False
) )
archive_exports(str(dashboard_dir), policy=RetentionPolicy()) archive_exports(str(dashboard_dir), policy=RetentionPolicy())
storage_log.debug(f"Archived dashboard: {dashboard_title}") storage_log.debug(f"Archived dashboard: {dashboard_title}")
backed_up_dashboards.append({
except (SupersetAPIError, RequestException, IOError, OSError) as db_error: "id": dashboard_id,
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}") "title": dashboard_title,
continue "path": str(dashboard_dir)
})
consolidate_archive_folders(backup_path / env.upper())
remove_empty_directories(str(backup_path / env.upper())) except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
log.info(f"Backup completed successfully for {env}") failed_dashboards.append({
"id": dashboard_id,
except (RequestException, IOError, KeyError) as e: "title": dashboard_title,
log.error(f"Fatal error during backup for {env}: {e}") "error": str(db_error)
raise e })
continue
consolidate_archive_folders(backup_path / env.upper())
remove_empty_directories(str(backup_path / env.upper()))
log.info(f"Backup completed successfully for {env}")
return {
"status": "SUCCESS" if not failed_dashboards else "PARTIAL_SUCCESS",
"environment": env,
"backup_root": str(backup_path / env.upper()),
"total_dashboards": total,
"backed_up_dashboards": len(backed_up_dashboards),
"failed_dashboards": len(failed_dashboards),
"dashboards": backed_up_dashboards,
"failures": failed_dashboards
}
except (RequestException, IOError, KeyError) as e:
log.error(f"Fatal error during backup for {env}: {e}")
raise e
# [/DEF:execute:Function] # [/DEF:execute:Function]
# [/DEF:BackupPlugin:Class] # [/DEF:BackupPlugin:Class]
# [/DEF:BackupPlugin:Module] # [/DEF:BackupPlugin:Module]

View File

@@ -165,11 +165,11 @@ class MigrationPlugin(PluginBase):
superset_log = log.with_source("superset_api") if context else log superset_log = log.with_source("superset_api") if context else log
migration_log = log.with_source("migration") if context else log migration_log = log.with_source("migration") if context else log
log.info("Starting migration task.") log.info("Starting migration task.")
log.debug(f"Params: {params}") log.debug(f"Params: {params}")
try: try:
with belief_scope("execute"): with belief_scope("execute"):
config_manager = get_config_manager() config_manager = get_config_manager()
environments = config_manager.get_environments() environments = config_manager.get_environments()
@@ -192,11 +192,20 @@ class MigrationPlugin(PluginBase):
from_env_name = src_env.name from_env_name = src_env.name
to_env_name = tgt_env.name to_env_name = tgt_env.name
log.info(f"Resolved environments: {from_env_name} -> {to_env_name}") log.info(f"Resolved environments: {from_env_name} -> {to_env_name}")
migration_result = {
from_c = SupersetClient(src_env) "status": "SUCCESS",
to_c = SupersetClient(tgt_env) "source_environment": from_env_name,
"target_environment": to_env_name,
"selected_dashboards": 0,
"migrated_dashboards": [],
"failed_dashboards": [],
"mapping_count": 0
}
from_c = SupersetClient(src_env)
to_c = SupersetClient(tgt_env)
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}")
@@ -204,20 +213,24 @@ class MigrationPlugin(PluginBase):
_, all_dashboards = from_c.get_dashboards() _, all_dashboards = from_c.get_dashboards()
dashboards_to_migrate = [] dashboards_to_migrate = []
if selected_ids: if selected_ids:
dashboards_to_migrate = [d for d in all_dashboards if d["id"] in selected_ids] dashboards_to_migrate = [d for d in all_dashboards if d["id"] in selected_ids]
elif dashboard_regex: elif dashboard_regex:
regex_str = str(dashboard_regex) regex_str = str(dashboard_regex)
dashboards_to_migrate = [ dashboards_to_migrate = [
d for d in all_dashboards if re.search(regex_str, d["dashboard_title"], re.IGNORECASE) d for d in all_dashboards if re.search(regex_str, d["dashboard_title"], re.IGNORECASE)
] ]
else: else:
log.warning("No selection criteria provided (selected_ids or dashboard_regex).") log.warning("No selection criteria provided (selected_ids or dashboard_regex).")
return migration_result["status"] = "NO_SELECTION"
return migration_result
if not dashboards_to_migrate:
log.warning("No dashboards found matching criteria.") if not dashboards_to_migrate:
return log.warning("No dashboards found matching criteria.")
migration_result["status"] = "NO_MATCHES"
return migration_result
migration_result["selected_dashboards"] = len(dashboards_to_migrate)
# Get mappings from params # Get mappings from params
db_mapping = params.get("db_mappings", {}) db_mapping = params.get("db_mappings", {})
@@ -238,17 +251,18 @@ class MigrationPlugin(PluginBase):
DatabaseMapping.target_env_id == tgt_env_db.id DatabaseMapping.target_env_id == tgt_env_db.id
).all() ).all()
# Provided mappings override stored ones # Provided mappings override stored ones
stored_map_dict = {m.source_db_uuid: m.target_db_uuid for m in stored_mappings} stored_map_dict = {m.source_db_uuid: m.target_db_uuid for m in stored_mappings}
stored_map_dict.update(db_mapping) stored_map_dict.update(db_mapping)
db_mapping = stored_map_dict db_mapping = stored_map_dict
log.info(f"Loaded {len(stored_mappings)} database mappings from database.") log.info(f"Loaded {len(stored_mappings)} database mappings from database.")
finally: finally:
db.close() db.close()
engine = MigrationEngine() migration_result["mapping_count"] = len(db_mapping)
engine = MigrationEngine()
for dash in dashboards_to_migrate:
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"] for dash in dashboards_to_migrate:
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"]
try: try:
exported_content, _ = from_c.export_dashboard(dash_id) exported_content, _ = from_c.export_dashboard(dash_id)
@@ -279,13 +293,22 @@ class MigrationPlugin(PluginBase):
db.close() db.close()
success = engine.transform_zip(str(tmp_zip_path), str(tmp_new_zip), db_mapping, strip_databases=False) success = engine.transform_zip(str(tmp_zip_path), str(tmp_new_zip), db_mapping, strip_databases=False)
if success: if success:
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug) to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
else: migration_result["migrated_dashboards"].append({
migration_log.error(f"Failed to transform ZIP for dashboard {title}") "id": dash_id,
"title": title
superset_log.info(f"Dashboard {title} imported.") })
except Exception as exc: else:
migration_log.error(f"Failed to transform ZIP for dashboard {title}")
migration_result["failed_dashboards"].append({
"id": dash_id,
"title": title,
"error": "Failed to transform ZIP"
})
superset_log.info(f"Dashboard {title} imported.")
except Exception as exc:
# Check for password error # Check for password error
error_msg = str(exc) error_msg = str(exc)
# The error message from Superset is often a JSON string inside a string. # The error message from Superset is often a JSON string inside a string.
@@ -324,22 +347,34 @@ class MigrationPlugin(PluginBase):
passwords = task.params.get("passwords", {}) passwords = task.params.get("passwords", {})
# Retry import with password # Retry import with password
if passwords: if passwords:
app_logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.") app_logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.")
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords) to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords)
app_logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.") app_logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.")
# Clear passwords from params after use for security migration_result["migrated_dashboards"].append({
if "passwords" in task.params: "id": dash_id,
del task.params["passwords"] "title": title
continue })
# Clear passwords from params after use for security
app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True) if "passwords" in task.params:
del task.params["passwords"]
app_logger.info("[MigrationPlugin][Exit] Migration finished.") continue
except Exception as e:
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True) app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
raise e migration_result["failed_dashboards"].append({
"id": dash_id,
"title": title,
"error": str(exc)
})
app_logger.info("[MigrationPlugin][Exit] Migration finished.")
if migration_result["failed_dashboards"]:
migration_result["status"] = "PARTIAL_SUCCESS"
return migration_result
except Exception as e:
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
raise e
# [/DEF:MigrationPlugin.execute:Action] # [/DEF:MigrationPlugin.execute:Action]
# [/DEF:execute:Function] # [/DEF:execute:Function]
# [/DEF:MigrationPlugin:Class] # [/DEF:MigrationPlugin:Class]
# [/DEF:MigrationPlugin:Module] # [/DEF:MigrationPlugin:Module]

View File

@@ -35,6 +35,7 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./config.json:/app/config.json
- ./backups:/app/backups - ./backups:/app/backups
- ./backend/git_repos:/app/backend/git_repos - ./backend/git_repos:/app/backend/git_repos

View File

@@ -0,0 +1,114 @@
<script>
let { task = null } = $props();
const result = $derived(task?.result || null);
const pluginId = $derived(task?.plugin_id || '');
function statusColor(status) {
switch (status) {
case 'PASS':
case 'SUCCESS':
return 'bg-green-100 text-green-700';
case 'WARN':
case 'PARTIAL_SUCCESS':
return 'bg-yellow-100 text-yellow-700';
case 'FAIL':
case 'FAILED':
return 'bg-red-100 text-red-700';
default:
return 'bg-slate-100 text-slate-700';
}
}
</script>
{#if !task}
<div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 p-6 text-sm text-slate-500">
Выберите задачу, чтобы увидеть результат.
</div>
{:else if !result}
<div class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-sm text-slate-700">Для этой задачи нет структурированного результата.</p>
</div>
{:else if pluginId === 'llm_dashboard_validation'}
<div class="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">LLM проверка дашборда</h3>
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${statusColor(result.status)}`}>{result.status || 'UNKNOWN'}</span>
</div>
<p class="text-sm text-slate-700">{result.summary || 'Нет summary'}</p>
{#if result.issues?.length}
<div>
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Проблемы ({result.issues.length})</p>
<ul class="space-y-2">
{#each result.issues as issue}
<li class="rounded-md border border-slate-200 bg-slate-50 p-2 text-sm">
<div class="flex items-center gap-2">
<span class={`rounded px-2 py-0.5 text-xs font-semibold ${statusColor(issue.severity)}`}>{issue.severity}</span>
<span class="text-slate-700">{issue.message}</span>
</div>
{#if issue.location}
<p class="mt-1 text-xs text-slate-500">Локация: {issue.location}</p>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
</div>
{:else if pluginId === 'superset-backup'}
<div class="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">Результат бэкапа</h3>
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${statusColor(result.status)}`}>{result.status || 'UNKNOWN'}</span>
</div>
<div class="grid grid-cols-2 gap-2 text-sm text-slate-700">
<p>Environment: {result.environment || '-'}</p>
<p>Total: {result.total_dashboards ?? 0}</p>
<p>Успешно: {result.backed_up_dashboards ?? 0}</p>
<p>Ошибок: {result.failed_dashboards ?? 0}</p>
</div>
{#if result.failures?.length}
<div>
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Ошибки</p>
<ul class="space-y-2">
{#each result.failures as failure}
<li class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700">
{failure.title || failure.id}: {failure.error}
</li>
{/each}
</ul>
</div>
{/if}
</div>
{:else if pluginId === 'superset-migration'}
<div class="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">Результат миграции</h3>
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${statusColor(result.status)}`}>{result.status || 'UNKNOWN'}</span>
</div>
<div class="grid grid-cols-2 gap-2 text-sm text-slate-700">
<p>Source: {result.source_environment || '-'}</p>
<p>Target: {result.target_environment || '-'}</p>
<p>Выбрано: {result.selected_dashboards ?? 0}</p>
<p>Успешно: {result.migrated_dashboards?.length ?? 0}</p>
<p>С ошибками: {result.failed_dashboards?.length ?? 0}</p>
<p>Mappings: {result.mapping_count ?? 0}</p>
</div>
{#if result.failed_dashboards?.length}
<div>
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Ошибки миграции</p>
<ul class="space-y-2">
{#each result.failed_dashboards as failed}
<li class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700">
{failed.title || failed.id}: {failed.error}
</li>
{/each}
</ul>
</div>
{/if}
</div>
{:else}
<div class="rounded-lg border border-slate-200 bg-white p-4">
<pre class="overflow-auto rounded bg-slate-50 p-3 text-xs text-slate-700">{JSON.stringify(result, null, 2)}</pre>
</div>
{/if}

View File

@@ -149,7 +149,19 @@ export const api = {
postApi, postApi,
requestApi, requestApi,
getPlugins: () => fetchApi('/plugins'), getPlugins: () => fetchApi('/plugins'),
getTasks: () => fetchApi('/tasks'), getTasks: (options = {}) => {
const params = new URLSearchParams();
if (options.limit != null) params.append('limit', String(options.limit));
if (options.offset != null) params.append('offset', String(options.offset));
if (options.status) params.append('status', options.status);
if (options.task_type) params.append('task_type', options.task_type);
if (options.completed_only != null) params.append('completed_only', String(Boolean(options.completed_only)));
if (Array.isArray(options.plugin_id)) {
options.plugin_id.forEach((pluginId) => params.append('plugin_id', pluginId));
}
const query = params.toString();
return fetchApi(`/tasks${query ? `?${query}` : ''}`);
},
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 }),

View File

@@ -8,20 +8,26 @@
--> -->
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { getTasks, createTask, getEnvironmentsList } from '../../lib/api'; import { getTasks } from '../../lib/api';
import { addToast } from '../../lib/toasts';
import TaskList from '../../components/TaskList.svelte'; import TaskList from '../../components/TaskList.svelte';
import TaskLogViewer from '../../components/TaskLogViewer.svelte'; import TaskLogViewer from '../../components/TaskLogViewer.svelte';
import TaskResultPanel from '../../components/tasks/TaskResultPanel.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { Button, Card, PageHeader, Select } from '$lib/ui'; import { PageHeader } from '$lib/ui';
let tasks = []; let tasks = [];
let environments = [];
let loading = true; let loading = true;
let selectedTaskId = null; let selectedTaskId = null;
let selectedTask = null;
let pollInterval; let pollInterval;
let showBackupModal = false; let taskTypeFilter = 'all';
let selectedEnvId = '';
const TASK_TYPE_OPTIONS = [
{ value: 'all', label: 'Все типы' },
{ value: 'llm_validation', label: 'LLM проверки' },
{ value: 'backup', label: 'Бэкапы' },
{ value: 'migration', label: 'Миграции' }
];
// [DEF:loadInitialData:Function] // [DEF:loadInitialData:Function]
/** /**
@@ -30,16 +36,19 @@
* @post tasks and environments variables are populated. * @post tasks and environments variables are populated.
*/ */
async function loadInitialData() { async function loadInitialData() {
console.log("[loadInitialData][Action] Loading initial tasks and environments"); console.log("[loadInitialData][Action] Loading completed tasks");
try { try {
loading = true; loading = true;
const [tasksData, envsData] = await Promise.all([ const tasksData = await getTasks({
getTasks(), limit: 100,
getEnvironmentsList() completed_only: true,
]); task_type: taskTypeFilter === 'all' ? undefined : taskTypeFilter
});
tasks = tasksData; tasks = tasksData;
environments = envsData; console.log(`[loadInitialData][Coherence:OK] Data loaded context={{'tasks': ${tasks.length}}}`);
console.log(`[loadInitialData][Coherence:OK] Data loaded context={{'tasks': ${tasks.length}, 'envs': ${environments.length}}}`); if (selectedTaskId && !tasks.some((task) => task.id === selectedTaskId)) {
selectedTaskId = null;
}
} catch (error) { } catch (error) {
console.error(`[loadInitialData][Coherence:Failed] Failed to load tasks data context={{'error': '${error.message}'}}`); console.error(`[loadInitialData][Coherence:Failed] Failed to load tasks data context={{'error': '${error.message}'}}`);
} finally { } finally {
@@ -56,7 +65,11 @@
*/ */
async function refreshTasks() { async function refreshTasks() {
try { try {
const data = await getTasks(); const data = await getTasks({
limit: 100,
completed_only: true,
task_type: taskTypeFilter === 'all' ? undefined : taskTypeFilter
});
// Ensure we don't try to parse HTML as JSON if the route returns 404 // Ensure we don't try to parse HTML as JSON if the route returns 404
if (Array.isArray(data)) { if (Array.isArray(data)) {
tasks = data; tasks = data;
@@ -79,32 +92,6 @@
} }
// [/DEF:handleSelectTask:Function] // [/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(() => { onMount(() => {
loadInitialData(); loadInitialData();
pollInterval = setInterval(refreshTasks, 3000); pollInterval = setInterval(refreshTasks, 3000);
@@ -113,6 +100,13 @@
onDestroy(() => { onDestroy(() => {
if (pollInterval) clearInterval(pollInterval); if (pollInterval) clearInterval(pollInterval);
}); });
function handleTaskTypeChange() {
selectedTaskId = null;
loadInitialData();
}
$: selectedTask = tasks.find((task) => task.id === selectedTaskId) || null;
</script> </script>
<div class="container mx-auto p-4 max-w-6xl"> <div class="container mx-auto p-4 max-w-6xl">
@@ -120,57 +114,46 @@
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<h2 class="text-lg font-semibold mb-3 text-gray-700">{$t.tasks.recent}</h2> <div class="mb-3 flex items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-gray-700">Результаты задач</h2>
<select
bind:value={taskTypeFilter}
on:change={handleTaskTypeChange}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-700 focus:border-blue-500 focus:outline-none"
>
{#each TASK_TYPE_OPTIONS as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<TaskList {tasks} {loading} on:select={handleSelectTask} /> <TaskList {tasks} {loading} on:select={handleSelectTask} />
</div> </div>
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<h2 class="text-lg font-semibold mb-3 text-gray-700">{$t.tasks.details_logs}</h2> <h2 class="text-lg font-semibold mb-3 text-gray-700">Результат и логи</h2>
{#if selectedTaskId} {#if selectedTaskId}
<Card padding="none"> <div class="space-y-4">
<div class="h-[600px] flex flex-col overflow-hidden rounded-lg"> <TaskResultPanel task={selectedTask} />
<TaskLogViewer <div class="rounded-lg border border-slate-200 bg-white">
taskId={selectedTaskId} <div class="border-b border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700">
taskStatus={tasks.find(t => t.id === selectedTaskId)?.status} Логи задачи
inline={true} </div>
/> <div class="h-[420px] flex flex-col overflow-hidden rounded-b-lg">
<TaskLogViewer
taskId={selectedTaskId}
taskStatus={selectedTask?.status}
inline={true}
/>
</div>
</div> </div>
</Card> </div>
{:else} {:else}
<div class="bg-gray-50 border-2 border-dashed border-gray-100 rounded-lg h-[600px] flex items-center justify-center text-gray-400"> <div class="bg-gray-50 border-2 border-dashed border-gray-100 rounded-lg h-[600px] flex items-center justify-center text-gray-400">
<p>{$t.tasks.select_task}</p> <p>Выберите задачу из списка слева</p>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
{#if showBackupModal} <!-- [/DEF:TaskManagementPage:Component] -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm p-4">
<div class="w-full max-w-md">
<Card title={$t.tasks.manual_backup}>
<div class="space-y-6">
<Select
label={$t.tasks.target_env}
bind:value={selectedEnvId}
options={[
{ value: '', label: $t.tasks.select_env },
...environments.map(e => ({ value: e.id, label: e.name }))
]}
/>
<div class="flex justify-end gap-3 pt-2">
<Button variant="secondary" on:click={() => showBackupModal = false}>
{$t.common.cancel}
</Button>
<Button variant="primary" on:click={handleRunBackup}>
Start Backup
</Button>
</div>
</div>
</Card>
</div>
</div>
{/if}
<!-- [/DEF:TaskManagementPage:Component] -->