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

@@ -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)

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]

View File

@@ -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}")

View File

@@ -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

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} />
<div class="rounded-lg border border-slate-200 bg-white">
<div class="border-b border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700">
Логи задачи
</div>
<div class="h-[420px] flex flex-col overflow-hidden rounded-b-lg">
<TaskLogViewer <TaskLogViewer
taskId={selectedTaskId} taskId={selectedTaskId}
taskStatus={tasks.find(t => t.id === selectedTaskId)?.status} taskStatus={selectedTask?.status}
inline={true} inline={true}
/> />
</div> </div>
</Card> </div>
</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] -->