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:
2026-07-02 18:53:58 +03:00
parent db998ce085
commit 6209647700
33 changed files with 3536 additions and 218 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View 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

View 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