feat(reports): Task Status Center — unified /reports dashboard
Страница /reports трансформирована в Центр статусов задач:
Backend:
- GET /api/reports/summary — агрегированные счётчики тип×статус (5 корзин)
- GET/PUT /api/settings/reports — глобальные настройки отчётов
- _filter_tasks_by_rbac() — row-level фильтрация по роли
- normalize_task_report: LLM-валидация с ошибками → FAILED/PARTIAL
- get_summary(): 5 корзин pending/running/awaiting_input/success/failed
Frontend:
- TaskCenterModel.svelte.ts (400 строк) — Screen Model
- SummaryPanel — сводная панель с цветовым кодированием и active filter
- ReportCard — humanized labels, duration, task_id, failed border
- FilterBar — search + sort + time range с label'ами
- Pagination — showingText, уникальные id для select
- Quick views: «Упавшие», «В работе», «Успешные»
- TaskDrawer: scroll-to-error, footer скрыт для terminal, «Н/Д» fix
Тесты: 48 backend + 38 frontend (2521 всего)
Build: ✅ Console errors: 0
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
// #region ReportsApi [C:3] [TYPE Module] [SEMANTICS report, api, search, filter, query]
|
||||
// @BRIEF Reports API client — list/detail retrieval, query-string building, error normalization.
|
||||
// @BRIEF Reports API client — list/detail retrieval, query-string building, error normalization, typed summary endpoint.
|
||||
// @LAYER API
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION DEPENDS_ON -> [ReportsTypes]
|
||||
// @PRE Shared API wrapper is configured for the current frontend auth/session context.
|
||||
// @POST Report list and detail helpers return backend payloads or normalized UI-safe errors.
|
||||
|
||||
import { api } from '../api';
|
||||
import type { ReportCollection, ReportDetailView, TaskSummary, ReportsSettings } from '$types/reports';
|
||||
|
||||
// #region reportApiTypes [C:1] [TYPE Block] [SEMANTICS types, reports]
|
||||
interface ReportQueryOptions {
|
||||
@@ -75,13 +77,13 @@ export function normalizeApiError(error: unknown): NormalizedError {
|
||||
// #region getReports [C:3] [TYPE Function] [SEMANTICS report, list, fetch]
|
||||
// @BRIEF Fetch unified report list using the shared API wrapper.
|
||||
// @PRE Valid auth context for protected endpoint.
|
||||
// @POST Returns parsed payload or throws normalized error.
|
||||
// @POST Returns parsed ReportCollection or throws normalized error.
|
||||
// @SIDE_EFFECT Performs authenticated GET request through api.fetchApi.
|
||||
// @RELATION DEPENDS_ON -> [fetchApi]
|
||||
export async function getReports<T = unknown>(options: ReportQueryOptions = {}): Promise<T> {
|
||||
export async function getReports(options: ReportQueryOptions = {}): Promise<ReportCollection> {
|
||||
try {
|
||||
const query = buildReportQueryString(options);
|
||||
return await api.fetchApi<T>(`/reports${query ? `?${query}` : ''}`);
|
||||
return await api.fetchApi<ReportCollection>(`/reports${query ? `?${query}` : ''}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
@@ -91,15 +93,43 @@ export async function getReports<T = unknown>(options: ReportQueryOptions = {}):
|
||||
// #region getReportDetail [C:3] [TYPE Function] [SEMANTICS report, detail, fetch]
|
||||
// @BRIEF Fetch one report detail by report_id.
|
||||
// @PRE reportId is non-empty string; valid auth context.
|
||||
// @POST Returns parsed detail payload or throws normalized error.
|
||||
// @POST Returns parsed ReportDetailView or throws normalized error.
|
||||
// @SIDE_EFFECT Performs authenticated GET request through api.fetchApi.
|
||||
// @RELATION DEPENDS_ON -> [fetchApi]
|
||||
export async function getReportDetail<T = unknown>(reportId: string): Promise<T> {
|
||||
export async function getReportDetail(reportId: string): Promise<ReportDetailView> {
|
||||
try {
|
||||
return await api.fetchApi<T>(`/reports/${reportId}`);
|
||||
return await api.fetchApi<ReportDetailView>(`/reports/${reportId}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// #endregion getReportDetail
|
||||
|
||||
// #region getReportsSummary [C:2] [TYPE Function] [SEMANTICS report, summary, fetch]
|
||||
// @BRIEF Fetch aggregated task summary for the summary dashboard.
|
||||
// @PRE Valid auth context.
|
||||
// @POST Returns parsed TaskSummary or throws normalized error.
|
||||
// @SIDE_EFFECT Performs authenticated GET request through api.fetchApi.
|
||||
export async function getReportsSummary(): Promise<TaskSummary> {
|
||||
try {
|
||||
return await api.fetchApi<TaskSummary>('/reports/summary');
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// #endregion getReportsSummary
|
||||
|
||||
// #region getReportsSettings [C:2] [TYPE Function] [SEMANTICS report, settings, fetch]
|
||||
// @BRIEF Fetch reports settings (admin-disabled task types).
|
||||
export async function getReportsSettings(): Promise<ReportsSettings> {
|
||||
return await api.fetchApi<ReportsSettings>('/settings/reports');
|
||||
}
|
||||
// #endregion getReportsSettings
|
||||
|
||||
// #region updateReportsSettings [C:2] [TYPE Function] [SEMANTICS report, settings, update]
|
||||
// @BRIEF Update reports settings (admin-only: disabled task types).
|
||||
export async function updateReportsSettings(settings: ReportsSettings): Promise<ReportsSettings> {
|
||||
return await api.requestApi<ReportsSettings>('/settings/reports', 'PUT', settings);
|
||||
}
|
||||
// #endregion updateReportsSettings
|
||||
// #endregion ReportsApi
|
||||
|
||||
@@ -82,6 +82,23 @@
|
||||
|
||||
let isOpen = $derived(Boolean(taskDrawerStore.value?.isOpen));
|
||||
let activeTaskId = $derived(taskDrawerStore.value?.activeTaskId || null);
|
||||
let scrollToHint = $derived(taskDrawerStore.value?.scrollToHint || null);
|
||||
|
||||
// Scroll to last error when opening a FAILED task
|
||||
$effect(() => {
|
||||
if (scrollToHint === 'last_error' && activeTaskId) {
|
||||
const timeout = setTimeout(() => {
|
||||
const drawer = document.querySelector('[role="dialog"]');
|
||||
if (drawer) {
|
||||
const logs = drawer.querySelectorAll('.text-destructive');
|
||||
const lastError = logs[logs.length - 1];
|
||||
if (lastError) lastError.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
else drawer.scrollTop = drawer.scrollHeight;
|
||||
}
|
||||
}, 600);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
let isAssistantOpen = $derived(Boolean(assistantChatStore.value?.isOpen));
|
||||
let assistantOffset = $derived(
|
||||
isAssistantOpen ? "min(100vw, 28rem)" : "0px",
|
||||
@@ -718,7 +735,7 @@
|
||||
disabled={!derivedTaskSummary?.primaryDashboardId ||
|
||||
!derivedTaskSummary?.targetEnvId}
|
||||
>
|
||||
{#if derivedTaskSummary?.targetEnvName}
|
||||
{#if derivedTaskSummary?.targetEnvName && derivedTaskSummary.targetEnvName !== '\u041D/\u0414'}
|
||||
{(
|
||||
$t.tasks?.open_dashboard_target || "Open dashboard in {env}"
|
||||
).replace("{env}", derivedTaskSummary.targetEnvName)}
|
||||
@@ -880,7 +897,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<!-- Footer — hidden for terminal states -->
|
||||
{#if taskStatus !== "SUCCESS" && taskStatus !== "FAILED"}
|
||||
<div
|
||||
class="flex items-center gap-2 justify-center px-4 py-3 border-t border-border bg-surface-page"
|
||||
>
|
||||
@@ -889,6 +907,7 @@
|
||||
{$t.tasks?.footer_text}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion TaskDrawer -->
|
||||
{/if}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { getReportTypeProfile } from "./reportTypeProfiles.js";
|
||||
import { formatDateTime } from "$lib/utils/dateFormat";
|
||||
import { formatDateTime, formatDuration } from "$lib/utils/dateFormat.js";
|
||||
|
||||
let { report, selected = false, onselect = () => {} } = $props();
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="w-full rounded-xl border p-4 text-left shadow-sm transition hover:border-border-strong hover:bg-surface-page hover:shadow {selected
|
||||
class="w-full rounded-xl border p-4 text-left transition hover:border-border-strong hover:bg-surface-page hover:shadow {selected
|
||||
? 'border-primary-ring bg-primary-light'
|
||||
: 'border-border bg-surface-card'}"
|
||||
: 'border-border bg-surface-card'} {report?.status === 'failed' ? 'border-l-4 border-l-destructive-ring' : ''}"
|
||||
onclick={onSelect}
|
||||
aria-label={`${$t.reports?.title} ${report?.report_id || ""} ${$t.reports?.type} ${profileLabel || $t.reports?.unknown_type}`}
|
||||
>
|
||||
@@ -92,7 +92,21 @@
|
||||
<p class="text-sm font-medium text-text">
|
||||
{report?.summary || $t.reports?.not_provided}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-text-muted">{formatDateTime(report?.updated_at)}</p>
|
||||
{#if report?.status === 'in_progress' && report?.error_context?.code === 'AWAITING_INPUT'}
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<span class="bg-warning-light text-warning rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
Ожидает ввода
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1.5 flex items-center justify-between gap-2 text-[11px] text-text-muted">
|
||||
<span class="font-mono truncate">{report?.task_id?.slice(0, 8) || report?.report_id?.slice(0, 8) || 'N/A'}</span>
|
||||
{#if report?.started_at && report?.updated_at}
|
||||
<span>{formatDuration(report.started_at, report.updated_at)}</span>
|
||||
{:else if report?.updated_at}
|
||||
<span>{formatDateTime(report.updated_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- #endregion ReportCard -->
|
||||
|
||||
95
frontend/src/lib/components/reports/SummaryPanel.svelte
Normal file
95
frontend/src/lib/components/reports/SummaryPanel.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<!-- #region SummaryPanel [C:4] [TYPE Component] [SEMANTICS component, summary, dashboard, websocket-reactive, task-status-center] -->
|
||||
<!-- @BRIEF Summary dashboard panel — grid of clickable cards showing task counts by type × status. -->
|
||||
<!-- @LAYER UI Component -->
|
||||
<!-- @UX_STATE idle: cards rendered normally, clickable -->
|
||||
<!-- @UX_STATE loading: skeleton placeholders (4 cards with pulse animation) -->
|
||||
<!-- @UX_STATE empty: all cards show zero counts -->
|
||||
<!-- @UX_STATE reconnecting: last known counts visible, yellow pulsing indicator -->
|
||||
<!-- @UX_STATE disconnected: last known counts visible, opacity reduced, "disconnected" badge -->
|
||||
<!-- @UX_FEEDBACK Count change: number animates (CSS transition). Card click: emits filter event. -->
|
||||
<!-- @UX_RECOVERY Disconnected: auto-reconnect indicator. Click → manual reconnect button. -->
|
||||
<!-- @UX_REACTIVITY Props: summary: TaskSummary | null, screenState: ScreenState. Derived: by_type grid. -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
|
||||
<!-- @PRE summary is non-null when screenState is 'ready' or 'disconnected'. -->
|
||||
<!-- @POST Clicking a card calls onFilterByTypeAndStatus(taskType, status). -->
|
||||
<script lang="ts">
|
||||
import type { TaskType, ReportStatus, ScreenState, StatusCounts, TaskSummary } from '$types/reports';
|
||||
|
||||
const STATUS_CONFIG: { key: keyof StatusCounts; label: string; colorClass: string }[] = [
|
||||
{ key: 'running', label: 'Выполняется', colorClass: 'bg-primary/10 text-primary' },
|
||||
{ key: 'pending', label: 'Ожидает', colorClass: 'bg-surface-muted text-text-muted' },
|
||||
{ key: 'awaiting_input', label: 'Ожидает ввода', colorClass: 'bg-warning-light text-warning' },
|
||||
{ key: 'success', label: 'Успешно', colorClass: 'bg-success-light text-success' },
|
||||
{ key: 'failed', label: 'Упало', colorClass: 'bg-destructive-light text-destructive' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
summary: TaskSummary | null;
|
||||
screenState: ScreenState;
|
||||
onFilterByTypeAndStatus: (_type: TaskType, _status: ReportStatus) => void;
|
||||
onReconnect?: () => void;
|
||||
activeFilters?: { task_types: TaskType[]; statuses: ReportStatus[] };
|
||||
}
|
||||
|
||||
let { summary, screenState, onFilterByTypeAndStatus, onReconnect = () => {}, activeFilters = { task_types: [], statuses: [] } }: Props = $props();
|
||||
|
||||
function getCount(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): number {
|
||||
const c = typeSummary.counts;
|
||||
return c[statusKey] ?? 0;
|
||||
}
|
||||
|
||||
function handleCardClick(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): void {
|
||||
// Map summary status keys to ReportStatus for filtering
|
||||
const statusMap: Record<string, ReportStatus> = {
|
||||
running: 'in_progress',
|
||||
pending: 'in_progress',
|
||||
awaiting_input: 'in_progress',
|
||||
success: 'success',
|
||||
failed: 'failed',
|
||||
};
|
||||
onFilterByTypeAndStatus(typeSummary.task_type, statusMap[statusKey] ?? 'in_progress');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if screenState === 'loading'}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{#each [1, 2, 3, 4] as n (n)}
|
||||
<div class="bg-surface-muted rounded-lg h-20 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if summary && summary.by_type.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each summary.by_type as typeSummary (typeSummary.task_type)}
|
||||
<!-- Type header -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-text text-sm font-medium">{typeSummary.display_label}</span>
|
||||
<span class="text-text-muted text-xs">({typeSummary.total})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
{#each STATUS_CONFIG as status (status.key)}
|
||||
{@const count = getCount(typeSummary, status.key)}
|
||||
{@const isActive = activeFilters.task_types.includes(typeSummary.task_type) && activeFilters.statuses.includes(status.key as ReportStatus)}
|
||||
<button
|
||||
class="flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-all hover:border-border-strong {screenState === 'disconnected' ? 'opacity-60' : ''} {status.colorClass} {isActive ? 'ring-2 ring-primary ring-offset-1' : ''}"
|
||||
onclick={() => handleCardClick(typeSummary, status.key)}
|
||||
aria-label="{typeSummary.display_label}, {status.label}: {count}"
|
||||
>
|
||||
<span class="text-sm font-mono min-w-[1.5rem] text-center font-semibold">{count}</span>
|
||||
<span class="text-xs truncate">{status.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{#if screenState === 'disconnected'}
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted mt-2">
|
||||
<span class="w-2 h-2 rounded-full bg-destructive"></span>
|
||||
Данные могут быть неактуальны
|
||||
<button class="underline cursor-pointer" onclick={onReconnect}>Подключиться</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- empty state - just placeholder -->
|
||||
<div class="text-text-muted text-sm py-4">Нет активных задач</div>
|
||||
{/if}
|
||||
<!-- #endregion SummaryPanel -->
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
// #region SummaryPanelTest [C:3] [TYPE Module] [SEMANTICS test,summary,ux,component]
|
||||
// @BRIEF UX tests for SummaryPanel component — states, transitions, click events.
|
||||
// @RELATION BINDS_TO -> [SummaryPanel]
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import SummaryPanel from '../SummaryPanel.svelte';
|
||||
import type { TaskSummary, ScreenState } from '$types/reports';
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('$lib/i18n/index.svelte.js', () => ({
|
||||
t: { subscribe: (fn: any) => { fn({}); return () => {}; } },
|
||||
_: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
const makeTypeSummary = (overrides = {}) => ({
|
||||
task_type: 'llm_verification' as const,
|
||||
display_label: 'llm_verification',
|
||||
icon_token: '',
|
||||
counts: { pending: 2, running: 3, awaiting_input: 1, success: 15, failed: 5 },
|
||||
total: 26,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSummary = (overrides = {}): TaskSummary => ({
|
||||
by_type: [makeTypeSummary()],
|
||||
total_tasks: 26,
|
||||
active_tasks: 6,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SummaryPanel — UX states', () => {
|
||||
const onFilter = vi.fn();
|
||||
const onReconnect = vi.fn();
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
// @UX_STATE: loading → skeleton placeholders (4 cards with pulse animation)
|
||||
it('shows skeleton cards in loading state', () => {
|
||||
const { container } = render(SummaryPanel, {
|
||||
summary: null,
|
||||
screenState: 'loading' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
// @UX_STATE: ready → cards rendered with counts
|
||||
it('renders type header and status counts in ready state', () => {
|
||||
render(SummaryPanel, {
|
||||
summary: makeSummary(),
|
||||
screenState: 'ready' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
expect(screen.getByText('llm_verification')).toBeDefined();
|
||||
expect(screen.getByText('(26)')).toBeDefined();
|
||||
});
|
||||
|
||||
// @UX_FEEDBACK: card click → calls onFilterByTypeAndStatus
|
||||
it('clicking a status card calls filter callback', async () => {
|
||||
render(SummaryPanel, {
|
||||
summary: makeSummary(),
|
||||
screenState: 'ready' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
const successButton = screen.getByLabelText('llm_verification, Успешно: 15');
|
||||
await fireEvent.click(successButton);
|
||||
expect(onFilter).toHaveBeenCalledWith('llm_verification', 'success');
|
||||
});
|
||||
|
||||
// @UX_STATE: empty → all zeros shown
|
||||
it('shows zero counts when summary has empty by_type', () => {
|
||||
const empty = makeSummary({ by_type: [], total_tasks: 0, active_tasks: 0 });
|
||||
render(SummaryPanel, {
|
||||
summary: empty,
|
||||
screenState: 'ready' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
expect(screen.getByText('Нет активных задач')).toBeDefined();
|
||||
});
|
||||
|
||||
// @UX_STATE: disconnected → opacity reduced + reconnect link
|
||||
it('shows disconnected indicator in disconnected state', () => {
|
||||
render(SummaryPanel, {
|
||||
summary: makeSummary(),
|
||||
screenState: 'disconnected' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
onReconnect,
|
||||
});
|
||||
expect(screen.getByText('Данные могут быть неактуальны')).toBeDefined();
|
||||
const reconnectBtn = screen.getByText('Подключиться');
|
||||
expect(reconnectBtn).toBeDefined();
|
||||
});
|
||||
|
||||
// @UX_RECOVERY: reconnect click → calls onReconnect
|
||||
it('clicking reconnect button calls onReconnect', async () => {
|
||||
render(SummaryPanel, {
|
||||
summary: makeSummary(),
|
||||
screenState: 'disconnected' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
onReconnect,
|
||||
});
|
||||
await fireEvent.click(screen.getByText('Подключиться'));
|
||||
expect(onReconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// @UX_STATE: multiple types rendered correctly
|
||||
it('renders multiple task types with their counts', () => {
|
||||
const multi = makeSummary({
|
||||
by_type: [
|
||||
makeTypeSummary({ task_type: 'backup' as const, display_label: 'backup', total: 5, counts: { pending: 1, running: 0, awaiting_input: 0, success: 4, failed: 0 } }),
|
||||
makeTypeSummary({ task_type: 'unknown' as const, display_label: 'unknown', total: 3, counts: { pending: 0, running: 0, awaiting_input: 0, success: 2, failed: 1 } }),
|
||||
],
|
||||
total_tasks: 8,
|
||||
});
|
||||
render(SummaryPanel, {
|
||||
summary: multi,
|
||||
screenState: 'ready' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
expect(screen.getByText('backup')).toBeDefined();
|
||||
expect(screen.getByText('unknown')).toBeDefined();
|
||||
// Second type has 1 failed
|
||||
expect(screen.getByLabelText('unknown, Упало: 1')).toBeDefined();
|
||||
});
|
||||
});
|
||||
// #endregion SummaryPanelTest
|
||||
@@ -40,6 +40,13 @@ export const REPORT_TYPE_PROFILES: Record<string, ReportTypeProfile> = {
|
||||
icon: 'file-text',
|
||||
fallback: false
|
||||
},
|
||||
clean_release: {
|
||||
key: 'clean_release',
|
||||
label: 'Clean Release',
|
||||
variant: 'bg-teal-100 text-teal-700',
|
||||
icon: 'shield-check',
|
||||
fallback: false
|
||||
},
|
||||
unknown: {
|
||||
key: 'unknown',
|
||||
label: () => _('reports.unknown_type'),
|
||||
|
||||
399
frontend/src/lib/models/TaskCenterModel.svelte.ts
Normal file
399
frontend/src/lib/models/TaskCenterModel.svelte.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
// #region TaskCenter.Model [C:4] [TYPE Model] [SEMANTICS model,task-center,summary,filtering,websocket,task-status-center]
|
||||
// @defgroup TaskCenter Screen Model for the Task Status Center (/reports).
|
||||
// @LAYER Store
|
||||
// @PRE Auth token in localStorage, user authenticated via root +layout.svelte.
|
||||
// @INVARIANT Active tasks always sorted before completed in filteredTasks. visibleSummary excludes disabledTypes AND not in visibleTypes. WS: connect on load, disconnect on destroy. DECOMPOSITION GATE: 400 lines, 40 methods (ADR-0010).
|
||||
// @STATE tasks, summary, filters, screenState, wsConnected, selectedTaskId, page, pageSize, disabledTypes, visibleTypes, lastUpdated
|
||||
// @ACTION loadInitialData, applyFilter, clearFilters, selectTask, refreshSummary, connectWebSocket, disconnectWebSocket, destroy, toggleTypeVisibility
|
||||
// @RELATION CALLS -> [ReportsApi]
|
||||
// @RELATION CALLS -> [ApiModule.getTaskEventsWsUrl]
|
||||
// @RELATION BINDS_TO -> [TaskDrawerStore]
|
||||
// @RELATION BINDS_TO -> [SummaryPanel]
|
||||
// @RELATION BINDS_TO -> [FilterBar]
|
||||
// @RELATION BINDS_TO -> [TaskList]
|
||||
// @DATA_CONTRACT Input: TaskCenterFilter → Output: filteredTasks: TaskReport[], visibleSummary: TaskSummary
|
||||
// @POST State mutations trigger $derived recompute. WS lifecycle managed.
|
||||
// @SIDE_EFFECT WS connect/disconnect, fetch API, URL replaceState, openDrawerForTask, localStorage read/write.
|
||||
// @RATIONALE Single model (R3) — 3 stories share same task dataset, ~350 lines, below 400-line gate.
|
||||
// @REJECTED Submodels from day one (premature), global store (page-scoped), polling (spec requires WS real-time).
|
||||
|
||||
import { log } from '$lib/cot-logger';
|
||||
import { api } from '$lib/api.js';
|
||||
import { getTaskEventsWsUrl } from '$lib/api.js';
|
||||
import { openDrawerForTask } from '$lib/stores/taskDrawer.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import type {
|
||||
TaskType,
|
||||
ReportStatus,
|
||||
ScreenState,
|
||||
TaskReport,
|
||||
TaskSummary,
|
||||
TaskCenterFilter,
|
||||
TaskStatusEvent,
|
||||
} from '$types/reports';
|
||||
|
||||
export type { TaskType, ReportStatus, ScreenState, TaskReport, TaskSummary, TaskCenterFilter };
|
||||
// ── Constants ──
|
||||
const ALL_TYPES: TaskType[] = ['llm_verification', 'backup', 'migration', 'documentation', 'clean_release', 'unknown'];
|
||||
const DEBOUNCE_MS = 100;
|
||||
const URL_SYNC_MS = 300;
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||
const STORAGE_KEY = 'taskCenter.visibleTypes';
|
||||
|
||||
const DEFAULT_FILTERS: TaskCenterFilter = {
|
||||
task_types: [],
|
||||
statuses: [],
|
||||
search: '',
|
||||
time_range: 'all',
|
||||
sort_by: 'updated_at',
|
||||
sort_order: 'desc',
|
||||
};
|
||||
// ── Helpers ──
|
||||
function isActiveStatus(status: ReportStatus): boolean {
|
||||
return status === 'in_progress';
|
||||
}
|
||||
function loadVisibleTypes(): TaskType[] {
|
||||
if (typeof localStorage === 'undefined') return [...ALL_TYPES];
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as TaskType[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
return [...ALL_TYPES];
|
||||
}
|
||||
function timeRangeToFrom(range: TaskCenterFilter['time_range']): Date | null {
|
||||
if (range === 'all') return null;
|
||||
const now = new Date();
|
||||
switch (range) {
|
||||
case '1h': return new Date(now.getTime() - 60 * 60 * 1000);
|
||||
case '24h': return new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
case '7d': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
case '30d': return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model ──
|
||||
|
||||
export class TaskCenterModel {
|
||||
// === ATOMS ===
|
||||
tasks: TaskReport[] = $state([]);
|
||||
summary: TaskSummary | null = $state(null);
|
||||
filters: TaskCenterFilter = $state({ ...DEFAULT_FILTERS });
|
||||
screenState: ScreenState = $state('loading');
|
||||
wsConnected: boolean = $state(false);
|
||||
selectedTaskId: string | null = $state(null);
|
||||
page: number = $state(1);
|
||||
pageSize: number = $state(20);
|
||||
disabledTypes: TaskType[] = $state([]);
|
||||
visibleTypes: TaskType[] = $state(loadVisibleTypes());
|
||||
lastUpdated: Date | null = $state(null);
|
||||
private _ws: WebSocket | null = null;
|
||||
private _eventBuffer: TaskStatusEvent[] = [];
|
||||
private _flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private _reconnectAttempt: number = 0;
|
||||
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _urlSyncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
totalItems: number = $state(0);
|
||||
private _hasNextPage: boolean = false;
|
||||
|
||||
// === DERIVED ===
|
||||
|
||||
get filteredTasks(): TaskReport[] {
|
||||
// Server-side filtered + sorted by sort_by/sort_order.
|
||||
// Client-side sort only for active-first ordering and WS-updated tasks.
|
||||
return [...this.tasks].sort((a, b) => {
|
||||
const aActive = isActiveStatus(a.status) ? 1 : 0;
|
||||
const bActive = isActiveStatus(b.status) ? 1 : 0;
|
||||
if (aActive !== bActive) return bActive - aActive;
|
||||
const dir = this.filters.sort_order === 'asc' ? 1 : -1;
|
||||
switch (this.filters.sort_by) {
|
||||
case 'status': return a.status.localeCompare(b.status) * dir;
|
||||
case 'task_type': return a.task_type.localeCompare(b.task_type) * dir;
|
||||
case 'created_at':
|
||||
case 'updated_at':
|
||||
default:
|
||||
return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * dir;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get visibleSummary(): TaskSummary | null {
|
||||
if (!this.summary) return null;
|
||||
const filteredByType = this.summary.by_type.filter(
|
||||
(ts) => !this.disabledTypes.includes(ts.task_type) && this.visibleTypes.includes(ts.task_type)
|
||||
);
|
||||
const totalTasks = filteredByType.reduce((sum, ts) => sum + ts.total, 0);
|
||||
const activeTasks = filteredByType.reduce(
|
||||
(sum, ts) => sum + ts.counts.pending + ts.counts.running + ts.counts.awaiting_input, 0
|
||||
);
|
||||
return { by_type: filteredByType, total_tasks: totalTasks, active_tasks: activeTasks };
|
||||
}
|
||||
|
||||
get isEmpty(): boolean {
|
||||
return this.tasks.length === 0 && this.screenState === 'ready';
|
||||
}
|
||||
|
||||
get isFilteredEmpty(): boolean {
|
||||
return this.filteredTasks.length === 0 && this.hasActiveFilters;
|
||||
}
|
||||
|
||||
get hasActiveFilters(): boolean {
|
||||
return (
|
||||
this.filters.task_types.length > 0 ||
|
||||
this.filters.statuses.length > 0 ||
|
||||
this.filters.search.trim() !== '' ||
|
||||
this.filters.time_range !== 'all'
|
||||
);
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.max(1, Math.ceil(this.totalItems / this.pageSize));
|
||||
}
|
||||
|
||||
get hasNextPage(): boolean {
|
||||
return this._hasNextPage;
|
||||
}
|
||||
|
||||
// === LIFECYCLE ===
|
||||
|
||||
async loadInitialData(initialFilters?: Partial<TaskCenterFilter>): Promise<void> {
|
||||
this.screenState = 'loading';
|
||||
log('TaskCenter.Model', 'REASON', 'Loading initial data');
|
||||
|
||||
if (initialFilters) {
|
||||
this.filters = { ...DEFAULT_FILTERS, ...initialFilters };
|
||||
}
|
||||
|
||||
try {
|
||||
// Parallel fetch: summary + settings + reports
|
||||
const [summaryData, settingsRes, listData] = await Promise.all([
|
||||
api.fetchApi<any>('/reports/summary'),
|
||||
api.fetchApi<any>('/settings/reports').catch(() => null), // non-critical
|
||||
api.fetchApi<any>(`/reports?${this._buildQueryString()}`),
|
||||
]);
|
||||
|
||||
this.summary = summaryData as TaskSummary;
|
||||
this.tasks = (listData?.items ?? []) as TaskReport[];
|
||||
this.totalItems = listData?.total ?? 0;
|
||||
this._hasNextPage = listData?.has_next ?? false;
|
||||
if (settingsRes) {
|
||||
this.disabledTypes = (settingsRes as any)?.disabled_task_types ?? [];
|
||||
}
|
||||
|
||||
this.lastUpdated = new Date();
|
||||
this.screenState = this.summary.total_tasks === 0 ? 'empty' : 'ready';
|
||||
log('TaskCenter.Model', 'REFLECT', 'Initial data loaded', {
|
||||
tasks: this.tasks.length, total: this.totalItems,
|
||||
});
|
||||
|
||||
// Connect WebSocket after initial load
|
||||
this.connectWebSocket();
|
||||
} catch (e: unknown) {
|
||||
this.screenState = 'error';
|
||||
log('TaskCenter.Model', 'EXPLORE', 'Initial load failed', {}, e instanceof Error ? e.message : 'unknown');
|
||||
addToast('Не удалось загрузить данные задач', 'error');
|
||||
}
|
||||
}
|
||||
async loadReports(): Promise<void> {
|
||||
log('TaskCenter.Model', 'REASON', 'Loading reports', { page: this.page });
|
||||
this.screenState = 'loading';
|
||||
try {
|
||||
const listData = await api.fetchApi<any>(`/reports?${this._buildQueryString()}`);
|
||||
this.tasks = (listData?.items ?? []) as TaskReport[];
|
||||
this.totalItems = listData?.total ?? 0;
|
||||
this._hasNextPage = listData?.has_next ?? false;
|
||||
this.lastUpdated = new Date();
|
||||
this.screenState = this.totalItems === 0 && !this.hasActiveFilters ? 'empty' : 'ready';
|
||||
log('TaskCenter.Model', 'REFLECT', 'Reports loaded', { tasks: this.tasks.length, total: this.totalItems });
|
||||
} catch (e: unknown) {
|
||||
this.screenState = 'error';
|
||||
log('TaskCenter.Model', 'EXPLORE', 'Load reports failed', {}, e instanceof Error ? e.message : 'unknown');
|
||||
addToast('Не удалось загрузить данные задач', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadPage(pageNum: number): Promise<void> {
|
||||
this.page = pageNum;
|
||||
await this.loadReports();
|
||||
}
|
||||
connectWebSocket(): void {
|
||||
if (this._ws) this.disconnectWebSocket();
|
||||
|
||||
const url = getTaskEventsWsUrl();
|
||||
this._ws = new WebSocket(url);
|
||||
|
||||
this._ws.onopen = () => {
|
||||
this.wsConnected = true;
|
||||
this._reconnectAttempt = 0;
|
||||
if (this.screenState === 'disconnected' || this.screenState === 'reconnecting') {
|
||||
this.screenState = 'ready';
|
||||
// Re-sync summary after reconnect (missed events)
|
||||
this.refreshSummary();
|
||||
}
|
||||
log('TaskCenter.Model', 'REFLECT', 'WebSocket connected');
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event: MessageEvent) => this._onWsMessage(event);
|
||||
|
||||
this._ws.onclose = (event: CloseEvent) => {
|
||||
this.wsConnected = false;
|
||||
if (event.code === 1000) return; // normal close
|
||||
|
||||
this.screenState = 'reconnecting';
|
||||
// After 2s of reconnecting, show disconnected banner
|
||||
setTimeout(() => {
|
||||
if (!this.wsConnected && this.screenState === 'reconnecting') {
|
||||
this.screenState = 'disconnected';
|
||||
}
|
||||
}, 2000);
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
// Error handled by onclose
|
||||
};
|
||||
}
|
||||
|
||||
disconnectWebSocket(): void {
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
if (this._flushTimeout) {
|
||||
clearTimeout(this._flushTimeout);
|
||||
this._flushTimeout = null;
|
||||
}
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // prevent reconnect trigger
|
||||
this._ws.close(1000);
|
||||
this._ws = null;
|
||||
}
|
||||
this.wsConnected = false;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.disconnectWebSocket();
|
||||
if (this._urlSyncTimeout) {
|
||||
clearTimeout(this._urlSyncTimeout);
|
||||
this._urlSyncTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// === ACTIONS ===
|
||||
|
||||
applyFilter(partial: Partial<TaskCenterFilter>): void {
|
||||
this.filters = { ...this.filters, ...partial };
|
||||
this.page = 1;
|
||||
this._syncFiltersToUrl();
|
||||
log('TaskCenter.Model', 'REASON', 'Filter applied', { partial });
|
||||
this.loadReports();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filters = { ...DEFAULT_FILTERS };
|
||||
this.page = 1;
|
||||
this._syncFiltersToUrl();
|
||||
log('TaskCenter.Model', 'REFLECT', 'Filters cleared');
|
||||
this.loadReports();
|
||||
}
|
||||
|
||||
selectTask(taskId: string): void {
|
||||
this.selectedTaskId = taskId;
|
||||
const task = this.tasks.find(t => t.task_id === taskId);
|
||||
const hint = task?.status === 'failed' ? 'last_error' : undefined;
|
||||
openDrawerForTask(taskId, hint);
|
||||
log('TaskCenter.Model', 'REASON', 'Task selected', { taskId, scrollToHint: hint });
|
||||
}
|
||||
async refreshSummary(): Promise<void> {
|
||||
try {
|
||||
const data = await api.fetchApi<any>('/reports/summary');
|
||||
this.summary = data as TaskSummary;
|
||||
this.lastUpdated = new Date();
|
||||
log('TaskCenter.Model', 'REFLECT', 'Summary refreshed');
|
||||
} catch (e: unknown) {
|
||||
log('TaskCenter.Model', 'EXPLORE', 'Summary refresh failed', {}, e instanceof Error ? e.message : 'unknown');
|
||||
}
|
||||
}
|
||||
|
||||
toggleTypeVisibility(type: TaskType): void {
|
||||
if (this.visibleTypes.includes(type)) {
|
||||
this.visibleTypes = this.visibleTypes.filter((t) => t !== type);
|
||||
} else {
|
||||
this.visibleTypes = [...this.visibleTypes, type];
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.visibleTypes));
|
||||
}
|
||||
log('TaskCenter.Model', 'REFLECT', 'Type visibility toggled', { type, visible: this.visibleTypes });
|
||||
}
|
||||
|
||||
// === PRIVATE METHODS ===
|
||||
|
||||
private _onWsMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as TaskStatusEvent;
|
||||
if (data.type === 'task_status') {
|
||||
this._eventBuffer.push(data);
|
||||
if (!this._flushTimeout) {
|
||||
this._flushTimeout = setTimeout(() => this._flushEventBuffer(), DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore malformed messages */ }
|
||||
}
|
||||
|
||||
private _flushEventBuffer(): void {
|
||||
this._flushTimeout = null;
|
||||
const count = this._eventBuffer.length;
|
||||
if (count === 0) return;
|
||||
log('TaskCenter.Model', 'REASON', 'Flushing WS events', { count });
|
||||
this._eventBuffer = [];
|
||||
this.lastUpdated = new Date();
|
||||
// Refresh summary for accurate aggregated counts (WS events carry raw Task, not normalized TaskReport)
|
||||
this.refreshSummary();
|
||||
}
|
||||
|
||||
private _scheduleReconnect(): void {
|
||||
const idx = Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1);
|
||||
const delay = RECONNECT_DELAYS[idx];
|
||||
this._reconnectAttempt++;
|
||||
log('TaskCenter.Model', 'EXPLORE', 'Scheduling reconnect', { attempt: this._reconnectAttempt, delay });
|
||||
this._reconnectTimer = setTimeout(() => this.connectWebSocket(), delay);
|
||||
}
|
||||
|
||||
private _buildQueryParams(): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (this.filters.task_types.length > 0) p.set('task_types', this.filters.task_types.join(','));
|
||||
if (this.filters.statuses.length > 0) p.set('statuses', this.filters.statuses.join(','));
|
||||
if (this.filters.search.trim()) p.set('search', this.filters.search.trim());
|
||||
const timeFrom = timeRangeToFrom(this.filters.time_range);
|
||||
if (timeFrom) p.set('time_from', timeFrom.toISOString());
|
||||
return p;
|
||||
}
|
||||
|
||||
private _syncFiltersToUrl(): void {
|
||||
if (this._urlSyncTimeout) clearTimeout(this._urlSyncTimeout);
|
||||
this._urlSyncTimeout = setTimeout(() => {
|
||||
if (typeof history === 'undefined') return;
|
||||
const p = this._buildQueryParams();
|
||||
if (this.filters.time_range !== 'all') p.set('range', this.filters.time_range);
|
||||
if (this.filters.sort_by !== 'updated_at') p.set('sort', this.filters.sort_by);
|
||||
if (this.filters.sort_order !== 'desc') p.set('order', this.filters.sort_order);
|
||||
if (this.page > 1) p.set('page', String(this.page));
|
||||
const qs = p.toString();
|
||||
history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname);
|
||||
}, URL_SYNC_MS);
|
||||
}
|
||||
|
||||
private _buildQueryString(): string {
|
||||
const p = this._buildQueryParams();
|
||||
p.set('page', String(this.page));
|
||||
p.set('page_size', String(this.pageSize));
|
||||
p.set('sort_by', this.filters.sort_by);
|
||||
p.set('sort_order', this.filters.sort_order);
|
||||
return p.toString();
|
||||
}
|
||||
}
|
||||
// #endregion TaskCenter.Model
|
||||
237
frontend/src/lib/models/__tests__/TaskCenterModel.test.ts
Normal file
237
frontend/src/lib/models/__tests__/TaskCenterModel.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
// #region TaskCenterModelTests [C:3] [TYPE Module] [SEMANTICS test,model,task-center]
|
||||
// @BRIEF L1 unit tests for TaskCenterModel — no DOM render.
|
||||
// @RELATION BINDS_TO -> [TaskCenterModel]
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mocks
|
||||
vi.mock("$lib/api.js", () => ({
|
||||
api: { fetchApi: vi.fn() },
|
||||
getTaskEventsWsUrl: vi.fn(() => "ws://test/events"),
|
||||
}));
|
||||
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
|
||||
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
|
||||
vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({ openDrawerForTask: vi.fn() }));
|
||||
|
||||
import { TaskCenterModel } from "../TaskCenterModel.svelte.ts";
|
||||
import { api } from "$lib/api.js";
|
||||
|
||||
// Sample data
|
||||
const makeTask = (id: string, status: string, taskType = "llm_verification") => ({
|
||||
report_id: id,
|
||||
task_id: id,
|
||||
task_type: taskType,
|
||||
status,
|
||||
summary: `Task ${id}`,
|
||||
updated_at: "2026-07-02T12:00:00Z",
|
||||
started_at: null,
|
||||
details: null,
|
||||
error_context: null,
|
||||
});
|
||||
|
||||
const makeSummary = (overrides = {}) => ({
|
||||
by_type: [
|
||||
{
|
||||
task_type: "llm_verification",
|
||||
display_label: "llm_verification",
|
||||
icon_token: "",
|
||||
counts: { pending: 1, running: 2, awaiting_input: 0, success: 10, failed: 3 },
|
||||
total: 16,
|
||||
},
|
||||
],
|
||||
total_tasks: 16,
|
||||
active_tasks: 3,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// #region TaskCenterModel.InvariantTests
|
||||
describe("TaskCenterModel — L1 invariants", () => {
|
||||
let model: TaskCenterModel;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
// Default mock: fetchApi returns empty data for initial load
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
|
||||
model = new TaskCenterModel();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// #region TaskCenterModel.InitialState
|
||||
it("starts in loading state with default filters", () => {
|
||||
expect(model.screenState).toBe("loading");
|
||||
expect(model.page).toBe(1);
|
||||
expect(model.pageSize).toBe(20);
|
||||
expect(model.filters).toEqual({
|
||||
task_types: [],
|
||||
statuses: [],
|
||||
search: '',
|
||||
time_range: 'all',
|
||||
sort_by: 'updated_at',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
expect(model.wsConnected).toBe(false);
|
||||
expect(model.tasks).toEqual([]);
|
||||
expect(model.summary).toBeNull();
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.FilteredTasks.OrdersActiveFirst
|
||||
it("active tasks sorted before completed in filteredTasks", () => {
|
||||
model.tasks = [
|
||||
makeTask("t3", "success"),
|
||||
makeTask("t1", "in_progress"),
|
||||
makeTask("t2", "failed"),
|
||||
makeTask("t4", "in_progress"),
|
||||
];
|
||||
const ft = model.filteredTasks;
|
||||
// First two should be in_progress (active), then success/failed
|
||||
expect(ft[0].status).toBe("in_progress");
|
||||
expect(ft[1].status).toBe("in_progress");
|
||||
expect(ft[2].status).toBe("success");
|
||||
expect(ft[3].status).toBe("failed");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.EmptySummaryShowsZeros
|
||||
it("visibleSummary returns zeros when summary is null", () => {
|
||||
expect(model.visibleSummary).toBeNull();
|
||||
model.summary = makeSummary();
|
||||
expect(model.visibleSummary).not.toBeNull();
|
||||
expect(model.visibleSummary!.total_tasks).toBe(16);
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.DisconnectedState
|
||||
it("screenState transitions to disconnected after abnormal WS close", () => {
|
||||
// Mock WebSocket with EventTarget-like interface
|
||||
let wsOnclose: ((e: any) => void) | null = null;
|
||||
class FakeWS {
|
||||
close = vi.fn();
|
||||
set onclose(fn: ((e: any) => void) | null) { wsOnclose = fn; }
|
||||
get onclose() { return wsOnclose; }
|
||||
set onopen(_fn: any) { /* noop — not needed for this test */ }
|
||||
get onopen() { return null; }
|
||||
set onerror(_fn: any) {}
|
||||
get onerror() { return null; }
|
||||
set onmessage(_fn: any) {}
|
||||
get onmessage() { return null; }
|
||||
}
|
||||
global.WebSocket = FakeWS as any;
|
||||
|
||||
model.connectWebSocket();
|
||||
// Simulate abnormal close
|
||||
wsOnclose?.({ code: 1006 } as CloseEvent);
|
||||
expect(model.screenState).toBe("reconnecting");
|
||||
|
||||
// After 2s timeout, should transition to disconnected
|
||||
vi.advanceTimersByTime(2100);
|
||||
expect(model.screenState).toBe("disconnected");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.ToggleVisibility
|
||||
it("toggleTypeVisibility adds/removes type from visibleTypes", () => {
|
||||
model.visibleTypes = ["llm_verification", "backup"];
|
||||
model.toggleTypeVisibility("llm_verification");
|
||||
expect(model.visibleTypes).toEqual(["backup"]);
|
||||
model.toggleTypeVisibility("llm_verification");
|
||||
expect(model.visibleTypes).toEqual(["backup", "llm_verification"]);
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.ApplyFilterResetsPage
|
||||
it("applyFilter resets page to 1 and triggers loadReports", async () => {
|
||||
model.page = 5;
|
||||
model.tasks = [makeTask("t1", "success")];
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
|
||||
await model.applyFilter({ task_types: ["backup"] });
|
||||
|
||||
expect(model.filters.task_types).toEqual(["backup"]);
|
||||
expect(model.page).toBe(1);
|
||||
// Should have called fetchApi to reload
|
||||
expect(api.fetchApi).toHaveBeenCalled();
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.ClearFilters
|
||||
it("clearFilters resets filters to defaults and reloads", async () => {
|
||||
model.filters = { task_types: ["backup"], statuses: ["failed"], search: "test", time_range: "24h", sort_by: "status", sort_order: "asc" };
|
||||
model.page = 3;
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
|
||||
await model.clearFilters();
|
||||
|
||||
expect(model.filters).toEqual({
|
||||
task_types: [], statuses: [], search: '', time_range: 'all', sort_by: 'updated_at', sort_order: 'desc',
|
||||
});
|
||||
expect(model.page).toBe(1);
|
||||
expect(api.fetchApi).toHaveBeenCalled();
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.SelectTask
|
||||
it("selectTask sets selectedTaskId and opens drawer", async () => {
|
||||
const { openDrawerForTask } = await import("$lib/stores/taskDrawer.svelte.js");
|
||||
model.selectTask("task-123");
|
||||
expect(model.selectedTaskId).toBe("task-123");
|
||||
expect(openDrawerForTask).toHaveBeenCalledWith("task-123", undefined);
|
||||
|
||||
// Test with failed task
|
||||
model.tasks = [makeTask("task-456", "failed")];
|
||||
model.selectTask("task-456");
|
||||
expect(openDrawerForTask).toHaveBeenCalledWith("task-456", "last_error");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.hasNextPage
|
||||
it("hasNextPage and totalPages derived correctly", () => {
|
||||
model.totalItems = 45;
|
||||
model.pageSize = 20;
|
||||
expect(model.totalPages).toBe(3); // ceil(45/20) = 3
|
||||
expect(model.hasNextPage).toBe(false); // _hasNextPage not set
|
||||
|
||||
model["_hasNextPage"] = true; // simulate API response
|
||||
expect(model.hasNextPage).toBe(true);
|
||||
});
|
||||
// #endregion
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.FilterTests
|
||||
describe("TaskCenterModel — filter invariants", () => {
|
||||
let model: TaskCenterModel;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
|
||||
model = new TaskCenterModel();
|
||||
});
|
||||
|
||||
// #region TaskCenterModel.HasActiveFilters
|
||||
it("hasActiveFilters returns true when filters are active", () => {
|
||||
expect(model.hasActiveFilters).toBe(false);
|
||||
model.filters.task_types = ["backup"];
|
||||
expect(model.hasActiveFilters).toBe(true);
|
||||
model.filters = { task_types: [], statuses: [], search: '', time_range: 'all', sort_by: 'updated_at', sort_order: 'desc' };
|
||||
expect(model.hasActiveFilters).toBe(false);
|
||||
model.filters.search = "test";
|
||||
expect(model.hasActiveFilters).toBe(true);
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.IsFilteredEmpty
|
||||
it("isFilteredEmpty checks tasks length and active filters", () => {
|
||||
expect(model.isFilteredEmpty).toBe(false); // no active filters
|
||||
model.filters.statuses = ["failed"];
|
||||
expect(model.isFilteredEmpty).toBe(true); // tasks empty + filters active
|
||||
model.tasks = [makeTask("t1", "failed")];
|
||||
expect(model.isFilteredEmpty).toBe(false); // tasks exist
|
||||
});
|
||||
// #endregion
|
||||
});
|
||||
// #endregion
|
||||
// #endregion TaskCenterModelTests
|
||||
@@ -20,6 +20,7 @@ interface ResourceTaskMap {
|
||||
interface TaskDrawerState {
|
||||
isOpen: boolean;
|
||||
activeTaskId: string | null;
|
||||
scrollToHint: string | null;
|
||||
resourceTaskMap: ResourceTaskMap;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ let autoOpenTaskDrawerPreference: boolean = readAutoOpenTaskDrawerPreference();
|
||||
const initialState: TaskDrawerState = {
|
||||
isOpen: false,
|
||||
activeTaskId: null,
|
||||
scrollToHint: null,
|
||||
resourceTaskMap: {},
|
||||
};
|
||||
|
||||
@@ -72,12 +74,12 @@ export const taskDrawerStore = {
|
||||
},
|
||||
};
|
||||
|
||||
export function openDrawerForTask(taskId: string): boolean {
|
||||
export function openDrawerForTask(taskId: string, scrollToHint?: string): boolean {
|
||||
if (!taskId) {
|
||||
return false;
|
||||
}
|
||||
if (_state.isOpen && _state.activeTaskId === taskId) return true; // Already open for this task
|
||||
_state = { ..._state, isOpen: true, activeTaskId: taskId };
|
||||
_state = { ..._state, isOpen: true, activeTaskId: taskId, scrollToHint: scrollToHint ?? null };
|
||||
_notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
page = $bindable(0),
|
||||
pageSize = 20,
|
||||
total = 0,
|
||||
showingText = '',
|
||||
pageSizeOptions = [] as number[],
|
||||
onPageSizeChange,
|
||||
class: className = "",
|
||||
@@ -49,7 +50,7 @@
|
||||
<!-- Summary -->
|
||||
<div class="text-sm text-text-muted">
|
||||
{#if total > 0}
|
||||
{($t.dashboard?.showing ?? "Showing {start}-{end} of {total}")
|
||||
{(showingText || $t.dashboard?.showing || "Showing {start}-{end} of {total}")
|
||||
.replace("{start}", String(start))
|
||||
.replace("{end}", String(end))
|
||||
.replace("{total}", String(total))}
|
||||
@@ -69,7 +70,7 @@
|
||||
if (onPageSizeChange) onPageSizeChange(val);
|
||||
}}
|
||||
>
|
||||
{#each pageSizeOptions as opt}
|
||||
{#each pageSizeOptions as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -78,7 +79,7 @@
|
||||
<!-- Page number buttons (only when > 1 page) -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex gap-1">
|
||||
{#each pageNumbers as p}
|
||||
{#each pageNumbers as p, i (String(p) + "-" + i)}
|
||||
{#if p === "..."}
|
||||
<span class="px-2 py-1 text-sm text-text-muted">…</span>
|
||||
{:else}
|
||||
|
||||
@@ -109,4 +109,24 @@ export function formatFileSize(bytes: number | null | undefined): string {
|
||||
}
|
||||
// #endregion formatFileSize:Function
|
||||
|
||||
// #region formatDuration:Function [TYPE Function]
|
||||
// @BRIEF Format duration between two dates as human-readable string (e.g. "2m 30s", "1h 15m").
|
||||
export function formatDuration(start: string | Date | null | undefined, end: string | Date | null | undefined): string {
|
||||
if (!start || !end) return "";
|
||||
const startDate = parseDateUTC(start);
|
||||
const endDate = parseDateUTC(end);
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return "";
|
||||
const diffMs = endDate.getTime() - startDate.getTime();
|
||||
if (diffMs < 0) return "";
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const sec = diffSec % 60;
|
||||
if (diffMin < 60) return sec > 0 ? `${diffMin}m ${sec}s` : `${diffMin}m`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const min = diffMin % 60;
|
||||
return min > 0 ? `${diffHour}h ${min}m` : `${diffHour}h`;
|
||||
}
|
||||
// #endregion formatDuration:Function
|
||||
|
||||
// #endregion Utils.DateFormat
|
||||
|
||||
@@ -1,207 +1,219 @@
|
||||
<!-- #region UnifiedReportsPage [C:3] [TYPE Page] [SEMANTICS sveltekit, report, filter, task, detail] -->
|
||||
<!-- @ingroup Routes -->
|
||||
<!-- @BRIEF Unified reports page with task-type and status filtering, list selection, detail panel, and resilient UX states for mixed task types. -->
|
||||
<!-- #region TaskCenterReportsPage [C:4] [TYPE Page] [SEMANTICS page, reports, task-center, layout, task-status-center] -->
|
||||
<!-- @BRIEF Task Status Center page — thin render layer over TaskCenterModel. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @RELATION DEPENDS_ON -> [ReportsApi] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:ReportsList] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:ReportDetailPanel] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [PageHeader] -->
|
||||
<!-- @UX_STATE Loading -> Skeleton block visible; filters remain interactive. -->
|
||||
<!-- @UX_STATE Ready -> Reports list rendered with selected report detail. -->
|
||||
<!-- @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 filter selections. -->
|
||||
<!-- @UX_FEEDBACK Filter change immediately reloads the list. -->
|
||||
<!-- @UX_FEEDBACK Selecting a report loads its detail in the right panel. -->
|
||||
<!-- @UX_RECOVERY Retry and clear-filters actions available. -->
|
||||
<!-- @UX_STATE loading: full-page skeleton -->
|
||||
<!-- @UX_STATE empty: "No tasks" empty state with navigation hints -->
|
||||
<!-- @UX_STATE ready: summary panel + filter bar + task list -->
|
||||
<!-- @UX_STATE disconnected: data visible with stale indicator + reconnect banner -->
|
||||
<!-- @UX_STATE error: error banner + retry button -->
|
||||
<!-- @UX_REACTIVITY Model: TaskCenterModel instance. Page is thin render layer. -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
|
||||
<!-- @RELATION BINDS_TO -> [SummaryPanel] -->
|
||||
<!-- @RELATION BINDS_TO -> [FilterBar] -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskList] -->
|
||||
<!-- @RELATION CALLS -> [TaskDrawer] -->
|
||||
<!-- @PRE User is authenticated (enforced by root +layout.svelte). -->
|
||||
<!-- @POST Page renders summary, filters, and task list. WebSocket connects automatically. -->
|
||||
<!-- @SIDE_EFFECT Initializes TaskCenterModel on mount. Connects WebSocket. Destroys on unmount. -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
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";
|
||||
import { onMount } from 'svelte';
|
||||
import { TaskCenterModel } from '$lib/models/TaskCenterModel.svelte.ts';
|
||||
import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte';
|
||||
import ReportsList from '$lib/components/reports/ReportsList.svelte';
|
||||
import { PageHeader, Button, EmptyState, Pagination, Input, Select } from '$lib/ui';
|
||||
import type { PageData } from './+page.ts';
|
||||
import type { TaskCenterFilter } from '$types/reports';
|
||||
|
||||
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 },
|
||||
{ value: "llm_verification", label: "LLM" },
|
||||
{ value: "backup", label: $t.nav?.backups },
|
||||
{ value: "migration", label: $t.nav?.migration },
|
||||
{ value: "documentation", label: "Documentation" },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all", label: $t.reports?.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) {
|
||||
const payload = event?.report ?? event?.detail?.report;
|
||||
if (!payload) return;
|
||||
selectedReport = payload;
|
||||
selectedReportDetail = await getReportDetail(selectedReport.report_id);
|
||||
}
|
||||
let { data }: { data: PageData } = $props();
|
||||
const m = new TaskCenterModel();
|
||||
|
||||
onMount(() => {
|
||||
loadReports();
|
||||
m.loadInitialData(data.initialFilters);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => m.destroy();
|
||||
});
|
||||
|
||||
// ── Pagination bridge (0-indexed ↔ 1-indexed) ──
|
||||
let pageZero = $state(m.page - 1);
|
||||
|
||||
$effect(() => {
|
||||
if (pageZero + 1 !== m.page) {
|
||||
m.loadPage(pageZero + 1);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (m.page - 1 !== pageZero) {
|
||||
pageZero = m.page - 1;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Search debounce ──
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleSearchInput(e: Event & { currentTarget: HTMLInputElement }): void {
|
||||
clearTimeout(searchTimeout);
|
||||
const value = e.currentTarget.value;
|
||||
searchTimeout = setTimeout(() => {
|
||||
m.applyFilter({ search: value });
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function handleSortChange(e: Event & { currentTarget: HTMLSelectElement }): void {
|
||||
m.applyFilter({ sort_by: e.currentTarget.value as TaskCenterFilter['sort_by'] });
|
||||
}
|
||||
|
||||
function handleTimeRangeChange(e: Event & { currentTarget: HTMLSelectElement }): void {
|
||||
m.applyFilter({ time_range: e.currentTarget.value as TaskCenterFilter['time_range'] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto w-full max-w-7xl space-y-4">
|
||||
<PageHeader
|
||||
title={$t.reports?.title}
|
||||
subtitle={() => null}
|
||||
actions={() => null}
|
||||
/>
|
||||
|
||||
<div class="rounded-xl border border-border bg-surface-card p-4 shadow-sm">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-4">
|
||||
<select
|
||||
bind:value={taskType}
|
||||
onchange={onFilterChange}
|
||||
class="rounded-md border border-border-strong 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}
|
||||
onchange={onFilterChange}
|
||||
class="rounded-md border border-border-strong px-2 py-1.5 text-sm"
|
||||
>
|
||||
{#each STATUS_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-lg border border-border-strong px-3 py-1.5 text-sm font-medium text-text transition-colors hover:bg-surface-page"
|
||||
onclick={() => loadReports()}
|
||||
>
|
||||
{$t.common?.refresh}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-lg border border-border-strong px-3 py-1.5 text-sm font-medium text-text transition-colors hover:bg-surface-page"
|
||||
onclick={clearFilters}
|
||||
>
|
||||
{$t.reports?.clear_filters}
|
||||
</button>
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 space-y-4">
|
||||
<PageHeader title="Центр статусов">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if m.wsConnected}
|
||||
<span class="w-3 h-3 rounded-full bg-success" aria-label="Соединение установлено"></span>
|
||||
{:else}
|
||||
<span class="w-3 h-3 rounded-full {m.screenState === 'reconnecting' ? 'bg-warning animate-pulse' : 'bg-destructive'}"
|
||||
aria-label="{m.screenState === 'reconnecting' ? 'Переподключение' : 'Соединение потеряно'}"></span>
|
||||
{/if}
|
||||
{#if m.lastUpdated}
|
||||
<span class="text-xs text-text-muted">
|
||||
Обновлено: {Math.round((Date.now() - m.lastUpdated.getTime()) / 60000)} мин назад
|
||||
</span>
|
||||
{/if}
|
||||
<Button variant="ghost" onclick={() => m.refreshSummary()}>Обновить</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{#if loading}
|
||||
<div
|
||||
class="rounded-xl border border-border bg-surface-card p-6 text-sm text-text-muted shadow-sm"
|
||||
>
|
||||
{$t.reports?.loading}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="rounded-xl border border-destructive-light bg-destructive-light p-4 text-destructive shadow-sm"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
class="mt-2 inline-flex items-center justify-center rounded-lg border border-destructive-ring px-3 py-1 text-sm font-medium text-destructive transition-colors hover:bg-destructive-light"
|
||||
onclick={() => loadReports()}
|
||||
>
|
||||
{$t.reports?.retry_load || $t.common?.retry}
|
||||
</button>
|
||||
</div>
|
||||
{:else if !collection || collection.total === 0}
|
||||
<div
|
||||
class="rounded-xl border border-border bg-surface-card p-6 text-sm text-text-muted shadow-sm"
|
||||
>
|
||||
{$t.reports?.empty}
|
||||
</div>
|
||||
{:else if collection.items.length === 0 && hasActiveFilters()}
|
||||
<div
|
||||
class="rounded-xl border border-border bg-surface-card p-6 text-sm text-text-muted shadow-sm"
|
||||
>
|
||||
<p>{$t.reports?.filtered_empty}</p>
|
||||
<button
|
||||
class="mt-2 inline-flex items-center justify-center rounded-lg border border-border-strong px-3 py-1 text-sm font-medium text-text transition-colors hover:bg-surface-page"
|
||||
onclick={clearFilters}
|
||||
>
|
||||
{$t.reports?.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}
|
||||
onselect={onSelectReport}
|
||||
/>
|
||||
</div>
|
||||
<ReportDetailPanel detail={selectedReportDetail} />
|
||||
{#if m.screenState === 'error'}
|
||||
<div class="bg-destructive-light border border-destructive-ring text-destructive p-3 rounded-lg flex items-center justify-between">
|
||||
<span>Не удалось загрузить данные задач. Сервер временно недоступен.</span>
|
||||
<Button variant="destructive" onclick={() => m.loadInitialData()}>Повторить</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- #endregion UnifiedReportsPage -->
|
||||
{#if m.screenState === 'disconnected'}
|
||||
<div class="bg-warning-light border border-warning-ring text-warning p-3 rounded-lg flex items-center justify-between">
|
||||
<span>Соединение потеряно. Попытка переподключения...</span>
|
||||
<Button variant="secondary" onclick={() => m.connectWebSocket()}>Подключиться сейчас</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Summary Panel -->
|
||||
<SummaryPanel
|
||||
summary={m.visibleSummary}
|
||||
screenState={m.screenState}
|
||||
onFilterByTypeAndStatus={(type, status) => m.applyFilter({ task_types: [type], statuses: [status] })}
|
||||
onReconnect={() => m.connectWebSocket()}
|
||||
activeFilters={{ task_types: m.filters.task_types, statuses: m.filters.statuses }}
|
||||
/>
|
||||
{#if m.screenState === 'ready' || m.screenState === 'disconnected'}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('failed') ? 'border-destructive-ring bg-destructive-light text-destructive' : 'border-border text-text-muted hover:border-destructive-ring hover:text-destructive'}"
|
||||
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('failed') ? [] : ['failed'] })}
|
||||
>
|
||||
Упавшие {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.failed, 0)}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('in_progress') ? 'border-primary-ring bg-primary/10 text-primary' : 'border-border text-text-muted hover:border-primary-ring hover:text-primary'}"
|
||||
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('in_progress') ? [] : ['in_progress'] })}
|
||||
>
|
||||
В работе {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.running, 0)}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('success') ? 'border-success-ring bg-success-light text-success' : 'border-border text-text-muted hover:border-success-ring hover:text-success'}"
|
||||
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('success') ? [] : ['success'] })}
|
||||
>
|
||||
Успешные {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.success, 0)}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="bg-surface-muted rounded-lg p-3 flex flex-wrap gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Поиск по задачам..."
|
||||
value={m.filters.search}
|
||||
oninput={handleSearchInput}
|
||||
class="w-48"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Сортировка</label>
|
||||
<Select
|
||||
id="sort-select"
|
||||
value={m.filters.sort_by}
|
||||
onchange={handleSortChange}
|
||||
class="w-36"
|
||||
>
|
||||
<option value="updated_at">По обновлению</option>
|
||||
<option value="created_at">По созданию</option>
|
||||
<option value="status">По статусу</option>
|
||||
<option value="task_type">По типу</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Период</label>
|
||||
<Select
|
||||
id="time-range-select"
|
||||
value={m.filters.time_range}
|
||||
onchange={handleTimeRangeChange}
|
||||
class="w-32"
|
||||
>
|
||||
<option value="all">Всё время</option>
|
||||
<option value="1h">Час</option>
|
||||
<option value="24h">24 часа</option>
|
||||
<option value="7d">7 дней</option>
|
||||
<option value="30d">30 дней</option>
|
||||
</Select>
|
||||
</div>
|
||||
<span class="text-xs text-text-muted ml-auto">{m.filteredTasks.length} задач(-и)</span>
|
||||
{#if m.hasActiveFilters}
|
||||
<Button variant="ghost" onclick={() => m.clearFilters()}>Сбросить фильтры</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Task list (basic for US1) -->
|
||||
<div class="bg-surface-card border border-border rounded-lg p-4">
|
||||
{#if m.screenState === 'loading'}
|
||||
<div class="space-y-3">
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
<div class="bg-surface-muted rounded h-12 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if m.isEmpty}
|
||||
<EmptyState
|
||||
title="Нет активных задач"
|
||||
description="В системе нет задач для отображения."
|
||||
/>
|
||||
{:else if m.isFilteredEmpty}
|
||||
<EmptyState
|
||||
title="Нет задач по выбранным фильтрам"
|
||||
description="Попробуйте изменить условия фильтрации."
|
||||
actionLabel="Сбросить фильтры"
|
||||
onAction={() => m.clearFilters()}
|
||||
/>
|
||||
{:else}
|
||||
<ReportsList
|
||||
reports={m.filteredTasks}
|
||||
selectedReportId={m.selectedTaskId}
|
||||
onselect={(e) => m.selectTask(e.report.task_id || e.report.report_id)}
|
||||
/>
|
||||
{#if m.totalPages > 1}
|
||||
<div class="mt-4">
|
||||
<Pagination
|
||||
bind:page={pageZero}
|
||||
pageSize={m.pageSize}
|
||||
total={m.totalItems}
|
||||
showingText={'Показано с {start} по {end} из {total} задач'}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion TaskCenterReportsPage -->
|
||||
|
||||
45
frontend/src/routes/reports/+page.ts
Normal file
45
frontend/src/routes/reports/+page.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// #region TaskCenterPageLoad [C:2] [TYPE Module] [SEMANTICS load, page, reports, task-status-center]
|
||||
// @BRIEF Load function for /reports — parses initial filters from URL query params.
|
||||
// @POST Returns initialFilters for TaskCenterModel.
|
||||
import type { TaskCenterFilter, TaskType, ReportStatus } from '$types/reports';
|
||||
|
||||
export interface PageData {
|
||||
initialFilters: Partial<TaskCenterFilter>;
|
||||
}
|
||||
|
||||
export function load({ url }: { url: URL }): PageData {
|
||||
const initialFilters: Partial<TaskCenterFilter> = {};
|
||||
|
||||
const typeParam = url.searchParams.get('task_types');
|
||||
if (typeParam) {
|
||||
initialFilters.task_types = typeParam.split(',').filter(Boolean) as TaskType[];
|
||||
}
|
||||
|
||||
const statusParam = url.searchParams.get('statuses');
|
||||
if (statusParam) {
|
||||
initialFilters.statuses = statusParam.split(',').filter(Boolean) as ReportStatus[];
|
||||
}
|
||||
|
||||
const search = url.searchParams.get('search');
|
||||
if (search) {
|
||||
initialFilters.search = search;
|
||||
}
|
||||
|
||||
const range = url.searchParams.get('range');
|
||||
if (range && ['1h', '24h', '7d', '30d'].includes(range)) {
|
||||
initialFilters.time_range = range as TaskCenterFilter['time_range'];
|
||||
}
|
||||
|
||||
const sort = url.searchParams.get('sort');
|
||||
if (sort && ['updated_at', 'created_at', 'status', 'task_type'].includes(sort)) {
|
||||
initialFilters.sort_by = sort as TaskCenterFilter['sort_by'];
|
||||
}
|
||||
|
||||
const order = url.searchParams.get('order');
|
||||
if (order && ['asc', 'desc'].includes(order)) {
|
||||
initialFilters.sort_order = order as TaskCenterFilter['sort_order'];
|
||||
}
|
||||
|
||||
return { initialFilters };
|
||||
}
|
||||
// #endregion TaskCenterPageLoad
|
||||
131
frontend/src/types/reports.ts
Normal file
131
frontend/src/types/reports.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// #region ReportsTypes [C:2] [TYPE Module] [SEMANTICS types,dto,reports,task-status-center]
|
||||
// @BRIEF TypeScript DTOs matching backend Pydantic schemas for the Task Status Center.
|
||||
// @DATA_CONTRACT Input: TypeScript interfaces → Output: typed API responses
|
||||
// @RELATION DEPENDS_ON -> [ReportModels] via @DATA_CONTRACT cross-stack
|
||||
// @RATIONALE Typed API responses prevent field mismatch bugs and enable IDE autocomplete.
|
||||
|
||||
/** Canonical task types matching backend TaskType enum. */
|
||||
export type TaskType =
|
||||
| 'llm_verification'
|
||||
| 'backup'
|
||||
| 'migration'
|
||||
| 'documentation'
|
||||
| 'clean_release'
|
||||
| 'unknown';
|
||||
|
||||
/** Normalized report status matching backend ReportStatus enum. */
|
||||
export type ReportStatus = 'success' | 'failed' | 'in_progress' | 'partial';
|
||||
|
||||
/** UI screen states for the Task Center page. */
|
||||
export type ScreenState = 'loading' | 'ready' | 'empty' | 'disconnected' | 'reconnecting' | 'error';
|
||||
|
||||
/** Error/recovery context for failed tasks. */
|
||||
export interface ErrorContext {
|
||||
code: string | null;
|
||||
message: string;
|
||||
next_actions: string[];
|
||||
}
|
||||
|
||||
/** Single task report — matches backend TaskReport. */
|
||||
export interface TaskReport {
|
||||
report_id: string;
|
||||
task_id: string;
|
||||
task_type: TaskType;
|
||||
status: ReportStatus;
|
||||
started_at: string | null;
|
||||
updated_at: string;
|
||||
summary: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
validation_record?: Record<string, unknown> | null;
|
||||
error_context: ErrorContext | null;
|
||||
source_ref?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Query parameters for report list filtering — matches backend ReportQuery. */
|
||||
export interface ReportQuery {
|
||||
page: number;
|
||||
page_size: number;
|
||||
task_types: TaskType[];
|
||||
statuses: ReportStatus[];
|
||||
time_from?: string | null;
|
||||
time_to?: string | null;
|
||||
search?: string | null;
|
||||
sort_by: 'updated_at' | 'created_at' | 'status' | 'task_type';
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** Paginated collection — matches backend ReportCollection. */
|
||||
export interface ReportCollection {
|
||||
items: TaskReport[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_next: boolean;
|
||||
applied_filters: ReportQuery;
|
||||
}
|
||||
|
||||
/** Detailed view — matches backend ReportDetailView. */
|
||||
export interface ReportDetailView {
|
||||
report: TaskReport;
|
||||
timeline: Record<string, unknown>[];
|
||||
diagnostics: Record<string, unknown> | null;
|
||||
next_actions: string[];
|
||||
}
|
||||
|
||||
/** Status counts for one task type in the summary. */
|
||||
export interface StatusCounts {
|
||||
pending: number;
|
||||
running: number;
|
||||
awaiting_input: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** Per-type summary entry. */
|
||||
export interface TaskTypeSummary {
|
||||
task_type: TaskType;
|
||||
display_label: string;
|
||||
icon_token: string;
|
||||
counts: StatusCounts;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Full summary response — matches backend TaskSummary. */
|
||||
export interface TaskSummary {
|
||||
by_type: TaskTypeSummary[];
|
||||
total_tasks: number;
|
||||
active_tasks: number;
|
||||
}
|
||||
|
||||
/** WebSocket task status event from /ws/task-events. */
|
||||
export interface TaskStatusEvent {
|
||||
type: 'task_status';
|
||||
task_id: string;
|
||||
task: {
|
||||
id: string;
|
||||
plugin_id: string;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
user_id: string | null;
|
||||
params: Record<string, unknown> | null;
|
||||
result: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter state for the Task Center UI (broader than ReportQuery). */
|
||||
export interface TaskCenterFilter {
|
||||
task_types: TaskType[];
|
||||
statuses: ReportStatus[];
|
||||
search: string;
|
||||
time_range: '1h' | '24h' | '7d' | '30d' | 'all';
|
||||
sort_by: 'updated_at' | 'created_at' | 'status' | 'task_type';
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** Reports settings (admin policy for group visibility). */
|
||||
export interface ReportsSettings {
|
||||
disabled_task_types: TaskType[];
|
||||
}
|
||||
|
||||
// #endregion ReportsTypes
|
||||
Reference in New Issue
Block a user