feat: restore legacy data and add typed task result views
This commit is contained in:
@@ -15,6 +15,12 @@ from ...dependencies import get_task_manager, has_permission, get_current_user
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
TASK_TYPE_PLUGIN_MAP = {
|
||||||
|
"llm_validation": ["llm_dashboard_validation"],
|
||||||
|
"backup": ["superset-backup"],
|
||||||
|
"migration": ["superset-migration"],
|
||||||
|
}
|
||||||
|
|
||||||
class CreateTaskRequest(BaseModel):
|
class CreateTaskRequest(BaseModel):
|
||||||
plugin_id: str
|
plugin_id: str
|
||||||
params: Dict[str, Any]
|
params: Dict[str, Any]
|
||||||
@@ -82,7 +88,10 @@ async def create_task(
|
|||||||
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_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
|
||||||
|
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),
|
task_manager: TaskManager = Depends(get_task_manager),
|
||||||
_ = Depends(has_permission("tasks", "READ"))
|
_ = Depends(has_permission("tasks", "READ"))
|
||||||
):
|
):
|
||||||
@@ -90,7 +99,22 @@ async def list_tasks(
|
|||||||
Retrieve a list of tasks with pagination and optional status filter.
|
Retrieve a list of tasks with pagination and optional status filter.
|
||||||
"""
|
"""
|
||||||
with belief_scope("list_tasks"):
|
with belief_scope("list_tasks"):
|
||||||
return task_manager.get_tasks(limit=limit, offset=offset, status=status)
|
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)
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -182,9 +182,20 @@ class BackupPlugin(PluginBase):
|
|||||||
|
|
||||||
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",
|
||||||
|
"environment": env,
|
||||||
|
"backup_root": str(backup_path / env.upper()),
|
||||||
|
"total_dashboards": 0,
|
||||||
|
"backed_up_dashboards": 0,
|
||||||
|
"failed_dashboards": 0,
|
||||||
|
"dashboards": [],
|
||||||
|
"failures": []
|
||||||
|
}
|
||||||
|
|
||||||
total = len(dashboard_meta)
|
total = len(dashboard_meta)
|
||||||
|
backed_up_dashboards = []
|
||||||
|
failed_dashboards = []
|
||||||
for idx, db in enumerate(dashboard_meta, 1):
|
for idx, db in enumerate(dashboard_meta, 1):
|
||||||
dashboard_id = db.get('id')
|
dashboard_id = db.get('id')
|
||||||
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
|
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
|
||||||
@@ -212,15 +223,35 @@ class BackupPlugin(PluginBase):
|
|||||||
|
|
||||||
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({
|
||||||
|
"id": dashboard_id,
|
||||||
|
"title": dashboard_title,
|
||||||
|
"path": str(dashboard_dir)
|
||||||
|
})
|
||||||
|
|
||||||
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
||||||
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
|
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
|
||||||
|
failed_dashboards.append({
|
||||||
|
"id": dashboard_id,
|
||||||
|
"title": dashboard_title,
|
||||||
|
"error": str(db_error)
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
consolidate_archive_folders(backup_path / env.upper())
|
consolidate_archive_folders(backup_path / env.upper())
|
||||||
remove_empty_directories(str(backup_path / env.upper()))
|
remove_empty_directories(str(backup_path / env.upper()))
|
||||||
|
|
||||||
log.info(f"Backup completed successfully for {env}")
|
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:
|
except (RequestException, IOError, KeyError) as e:
|
||||||
log.error(f"Fatal error during backup for {env}: {e}")
|
log.error(f"Fatal error during backup for {env}: {e}")
|
||||||
|
|||||||
@@ -194,6 +194,15 @@ class MigrationPlugin(PluginBase):
|
|||||||
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 = {
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"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)
|
from_c = SupersetClient(src_env)
|
||||||
to_c = SupersetClient(tgt_env)
|
to_c = SupersetClient(tgt_env)
|
||||||
@@ -213,11 +222,15 @@ class MigrationPlugin(PluginBase):
|
|||||||
]
|
]
|
||||||
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:
|
if not dashboards_to_migrate:
|
||||||
log.warning("No dashboards found matching criteria.")
|
log.warning("No dashboards found matching criteria.")
|
||||||
return
|
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", {})
|
||||||
@@ -245,6 +258,7 @@ class MigrationPlugin(PluginBase):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
migration_result["mapping_count"] = len(db_mapping)
|
||||||
engine = MigrationEngine()
|
engine = MigrationEngine()
|
||||||
|
|
||||||
for dash in dashboards_to_migrate:
|
for dash in dashboards_to_migrate:
|
||||||
@@ -281,8 +295,17 @@ class MigrationPlugin(PluginBase):
|
|||||||
|
|
||||||
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)
|
||||||
|
migration_result["migrated_dashboards"].append({
|
||||||
|
"id": dash_id,
|
||||||
|
"title": title
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
migration_log.error(f"Failed to transform ZIP for dashboard {title}")
|
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.")
|
superset_log.info(f"Dashboard {title} imported.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -328,14 +351,26 @@ class MigrationPlugin(PluginBase):
|
|||||||
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.")
|
||||||
|
migration_result["migrated_dashboards"].append({
|
||||||
|
"id": dash_id,
|
||||||
|
"title": title
|
||||||
|
})
|
||||||
# Clear passwords from params after use for security
|
# Clear passwords from params after use for security
|
||||||
if "passwords" in task.params:
|
if "passwords" in task.params:
|
||||||
del task.params["passwords"]
|
del task.params["passwords"]
|
||||||
continue
|
continue
|
||||||
|
|
||||||
app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
|
app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
|
||||||
|
migration_result["failed_dashboards"].append({
|
||||||
|
"id": dash_id,
|
||||||
|
"title": title,
|
||||||
|
"error": str(exc)
|
||||||
|
})
|
||||||
|
|
||||||
app_logger.info("[MigrationPlugin][Exit] Migration finished.")
|
app_logger.info("[MigrationPlugin][Exit] Migration finished.")
|
||||||
|
if migration_result["failed_dashboards"]:
|
||||||
|
migration_result["status"] = "PARTIAL_SUCCESS"
|
||||||
|
return migration_result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
|
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
|
||||||
raise e
|
raise e
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
114
frontend/src/components/tasks/TaskResultPanel.svelte
Normal file
114
frontend/src/components/tasks/TaskResultPanel.svelte
Normal 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}
|
||||||
@@ -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 }),
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
<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] -->
|
<!-- [/DEF:TaskManagementPage:Component] -->
|
||||||
Reference in New Issue
Block a user