таски готовы
This commit is contained in:
194
frontend/src/routes/reports/+page.svelte
Normal file
194
frontend/src/routes/reports/+page.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<!-- [DEF:UnifiedReportsPage:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, unified, filters, loading, empty, error
|
||||
* @PURPOSE: Unified reports page with filtering and resilient UX states for mixed task types.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/api/reports.js
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/components/reports/ReportsList.svelte
|
||||
* @INVARIANT: List state remains deterministic for active filter set.
|
||||
*
|
||||
* @UX_STATE: Loading -> Skeleton-like block shown; filters visible.
|
||||
* @UX_STATE: Ready -> Reports list rendered.
|
||||
* @UX_STATE: NoData -> Friendly empty state for total=0 without filters.
|
||||
* @UX_STATE: FilteredEmpty -> Filtered empty state with one-click clear.
|
||||
* @UX_STATE: Error -> Inline error with retry preserving filters.
|
||||
* @UX_FEEDBACK: Filter change reloads list immediately.
|
||||
* @UX_RECOVERY: Retry and clear filters actions available.
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { PageHeader } from '$lib/ui';
|
||||
import { getReports, getReportDetail } from '$lib/api/reports.js';
|
||||
import ReportsList from '$lib/components/reports/ReportsList.svelte';
|
||||
import ReportDetailPanel from '$lib/components/reports/ReportDetailPanel.svelte';
|
||||
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let collection = null;
|
||||
let selectedReport = null;
|
||||
let selectedReportDetail = null;
|
||||
|
||||
let taskType = 'all';
|
||||
let status = 'all';
|
||||
let page = 1;
|
||||
const pageSize = 20;
|
||||
|
||||
const TASK_TYPE_OPTIONS = [
|
||||
{ value: 'all', label: $t.reports?.all_types || 'All types' },
|
||||
{ value: 'llm_verification', label: 'LLM' },
|
||||
{ value: 'backup', label: $t.nav?.backups || 'Backups' },
|
||||
{ value: 'migration', label: $t.nav?.migration || 'Migration' },
|
||||
{ value: 'documentation', label: 'Documentation' }
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: $t.reports?.all_statuses || 'All statuses' },
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'in_progress', label: 'In progress' },
|
||||
{ value: 'partial', label: 'Partial' }
|
||||
];
|
||||
|
||||
function buildQuery() {
|
||||
return {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
task_types: taskType === 'all' ? [] : [taskType],
|
||||
statuses: status === 'all' ? [] : [status],
|
||||
sort_by: 'updated_at',
|
||||
sort_order: 'desc'
|
||||
};
|
||||
}
|
||||
|
||||
async function loadReports({ silent = false } = {}) {
|
||||
try {
|
||||
if (!silent) loading = true;
|
||||
error = '';
|
||||
collection = await getReports(buildQuery());
|
||||
if (!selectedReport && collection?.items?.length) {
|
||||
selectedReport = collection.items[0];
|
||||
selectedReportDetail = await getReportDetail(selectedReport.report_id);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e?.message || 'Failed to load reports';
|
||||
collection = null;
|
||||
} finally {
|
||||
if (!silent) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasActiveFilters() {
|
||||
return taskType !== 'all' || status !== 'all';
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
taskType = 'all';
|
||||
status = 'all';
|
||||
page = 1;
|
||||
selectedReport = null;
|
||||
selectedReportDetail = null;
|
||||
loadReports();
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page = 1;
|
||||
selectedReport = null;
|
||||
selectedReportDetail = null;
|
||||
loadReports();
|
||||
}
|
||||
|
||||
async function onSelectReport(event) {
|
||||
selectedReport = event.detail.report;
|
||||
selectedReportDetail = await getReportDetail(selectedReport.report_id);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadReports();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-6xl p-4">
|
||||
<PageHeader
|
||||
title={$t.reports?.title || 'Reports'}
|
||||
subtitle={() => null}
|
||||
actions={() => null}
|
||||
/>
|
||||
|
||||
<div class="mb-4 rounded-lg border border-slate-200 bg-white p-3">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-4">
|
||||
<select
|
||||
bind:value={taskType}
|
||||
on:change={onFilterChange}
|
||||
class="rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{#each TASK_TYPE_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
bind:value={status}
|
||||
on:change={onFilterChange}
|
||||
class="rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{#each STATUS_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="rounded-md border border-slate-200 px-3 py-1.5 text-sm hover:bg-slate-50"
|
||||
on:click={() => loadReports()}
|
||||
>
|
||||
{$t.common?.refresh || 'Refresh'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-md border border-slate-200 px-3 py-1.5 text-sm hover:bg-slate-50"
|
||||
on:click={clearFilters}
|
||||
>
|
||||
{$t.reports?.clear_filters || 'Clear filters'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 text-slate-500">
|
||||
{$t.common?.loading || 'Loading...'}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700">
|
||||
<p>{error}</p>
|
||||
<button class="mt-2 rounded border border-red-300 px-3 py-1 text-sm" on:click={() => loadReports()}>
|
||||
{$t.common?.retry || 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
{:else if !collection || collection.total === 0}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 text-slate-500">
|
||||
{$t.reports?.empty || 'No reports available.'}
|
||||
</div>
|
||||
{:else if collection.items.length === 0 && hasActiveFilters()}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 text-slate-500">
|
||||
<p>{$t.reports?.filtered_empty || 'No reports match your filters.'}</p>
|
||||
<button class="mt-2 rounded border border-slate-200 px-3 py-1 text-sm hover:bg-slate-50" on:click={clearFilters}>
|
||||
{$t.reports?.clear_filters || 'Clear filters'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<ReportsList
|
||||
reports={collection?.items || []}
|
||||
selectedReportId={selectedReport?.report_id}
|
||||
on:select={onSelectReport}
|
||||
/>
|
||||
</div>
|
||||
<ReportDetailPanel detail={selectedReportDetail} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:UnifiedReportsPage:Component] -->
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getTasks } from '../../lib/api';
|
||||
import { getTasks, getTask } from '../../lib/api';
|
||||
import TaskList from '../../components/TaskList.svelte';
|
||||
import TaskLogViewer from '../../components/TaskLogViewer.svelte';
|
||||
import TaskResultPanel from '../../components/tasks/TaskResultPanel.svelte';
|
||||
@@ -17,10 +17,14 @@
|
||||
|
||||
let tasks = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let selectedTaskId = null;
|
||||
let selectedTask = null;
|
||||
let pollInterval;
|
||||
let taskTypeFilter = 'all';
|
||||
let pageSize = 20;
|
||||
let currentPage = 1;
|
||||
let hasNextPage = false;
|
||||
|
||||
const TASK_TYPE_OPTIONS = [
|
||||
{ value: 'all', label: 'Все типы' },
|
||||
@@ -29,33 +33,49 @@
|
||||
{ value: 'migration', label: 'Миграции' }
|
||||
];
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50];
|
||||
|
||||
// [DEF:loadInitialData:Function]
|
||||
/**
|
||||
* @purpose Loads tasks and environments on page initialization.
|
||||
* @pre API must be reachable.
|
||||
* @post tasks and environments variables are populated.
|
||||
*/
|
||||
async function loadInitialData() {
|
||||
console.log("[loadInitialData][Action] Loading completed tasks");
|
||||
async function loadTasks({ silent = false } = {}) {
|
||||
try {
|
||||
loading = true;
|
||||
if (!silent) loading = true;
|
||||
error = '';
|
||||
const tasksData = await getTasks({
|
||||
limit: 100,
|
||||
limit: pageSize + 1,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
completed_only: true,
|
||||
task_type: taskTypeFilter === 'all' ? undefined : taskTypeFilter
|
||||
});
|
||||
tasks = tasksData;
|
||||
console.log(`[loadInitialData][Coherence:OK] Data loaded context={{'tasks': ${tasks.length}}}`);
|
||||
if (selectedTaskId && !tasks.some((task) => task.id === selectedTaskId)) {
|
||||
selectedTaskId = null;
|
||||
|
||||
if (Array.isArray(tasksData)) {
|
||||
hasNextPage = tasksData.length > pageSize;
|
||||
tasks = hasNextPage ? tasksData.slice(0, pageSize) : tasksData;
|
||||
} else {
|
||||
tasks = [];
|
||||
hasNextPage = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[loadInitialData][Coherence:Failed] Failed to load tasks data context={{'error': '${error.message}'}}`);
|
||||
|
||||
if (selectedTaskId) {
|
||||
const taskFromPage = tasks.find((task) => task.id === selectedTaskId);
|
||||
if (taskFromPage) {
|
||||
selectedTask = taskFromPage;
|
||||
} else {
|
||||
selectedTask = await getTask(selectedTaskId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[loadTasks][Coherence:Failed] Failed to load tasks data context={{'error': '${err.message}'}}`);
|
||||
error = err.message || 'Не удалось загрузить задачи';
|
||||
} finally {
|
||||
loading = false;
|
||||
if (!silent) loading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:loadInitialData:Function]
|
||||
// [/DEF:loadTasks:Function]
|
||||
|
||||
// [DEF:refreshTasks:Function]
|
||||
/**
|
||||
@@ -64,19 +84,7 @@
|
||||
* @post tasks variable is updated if data is valid.
|
||||
*/
|
||||
async function refreshTasks() {
|
||||
try {
|
||||
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
|
||||
if (Array.isArray(data)) {
|
||||
tasks = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[refreshTasks][Coherence:Failed] Failed to refresh tasks context={{'error': '${error.message}'}}`);
|
||||
}
|
||||
await loadTasks({ silent: true });
|
||||
}
|
||||
// [/DEF:refreshTasks:Function]
|
||||
|
||||
@@ -88,12 +96,13 @@
|
||||
*/
|
||||
function handleSelectTask(event) {
|
||||
selectedTaskId = event.detail.id;
|
||||
selectedTask = event.detail.task || null;
|
||||
console.log(`[handleSelectTask][Action] Task selected context={{'taskId': '${selectedTaskId}'}}`);
|
||||
}
|
||||
// [/DEF:handleSelectTask:Function]
|
||||
|
||||
onMount(() => {
|
||||
loadInitialData();
|
||||
loadTasks();
|
||||
pollInterval = setInterval(refreshTasks, 3000);
|
||||
});
|
||||
|
||||
@@ -102,43 +111,107 @@
|
||||
});
|
||||
|
||||
function handleTaskTypeChange() {
|
||||
currentPage = 1;
|
||||
selectedTaskId = null;
|
||||
loadInitialData();
|
||||
selectedTask = null;
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
$: selectedTask = tasks.find((task) => task.id === selectedTaskId) || null;
|
||||
function handlePageSizeChange() {
|
||||
currentPage = 1;
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
function goToPrevPage() {
|
||||
if (currentPage === 1) return;
|
||||
currentPage -= 1;
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
function goToNextPage() {
|
||||
if (!hasNextPage) return;
|
||||
currentPage += 1;
|
||||
loadTasks();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4 max-w-6xl">
|
||||
<PageHeader title={$t.tasks.management} />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||
<div class="lg:col-span-1">
|
||||
<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 class="rounded-lg border border-slate-200 bg-white p-3 lg:sticky lg:top-20">
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold text-gray-700">Результаты задач</h2>
|
||||
<button
|
||||
on:click={() => loadTasks()}
|
||||
class="rounded-md border border-slate-200 px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
{$t.tasks?.refresh || 'Обновить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||
<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>
|
||||
<select
|
||||
bind:value={pageSize}
|
||||
on:change={handlePageSizeChange}
|
||||
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 PAGE_SIZE_OPTIONS as size}
|
||||
<option value={size}>{size} / стр</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-[58vh] overflow-y-auto rounded-md border border-slate-100">
|
||||
<TaskList tasks={tasks} {loading} {selectedTaskId} on:select={handleSelectTask} />
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2 text-sm">
|
||||
<button
|
||||
on:click={goToPrevPage}
|
||||
disabled={currentPage === 1}
|
||||
class="rounded-md border border-slate-200 px-3 py-1.5 text-slate-700 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-slate-50"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span class="text-slate-500">Страница {currentPage}</span>
|
||||
<button
|
||||
on:click={goToNextPage}
|
||||
disabled={!hasNextPage}
|
||||
class="rounded-md border border-slate-200 px-3 py-1.5 text-slate-700 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-slate-50"
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TaskList {tasks} {loading} on:select={handleSelectTask} />
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-lg font-semibold mb-3 text-gray-700">Результат и логи</h2>
|
||||
{#if selectedTaskId}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 min-h-[60vh]">
|
||||
<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">
|
||||
<div class="h-[56vh] min-h-[360px] flex flex-col overflow-hidden rounded-b-lg">
|
||||
<TaskLogViewer
|
||||
taskId={selectedTaskId}
|
||||
taskStatus={selectedTask?.status}
|
||||
@@ -148,7 +221,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{: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-[60vh] min-h-[380px] flex items-center justify-center text-gray-400">
|
||||
<p>Выберите задачу из списка слева</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user