Страница /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
22 KiB
22 KiB
#region ModuleContracts [C:5] [TYPE ADR] [SEMANTICS contracts, modules, grace, task-status-center, backend, frontend] @BRIEF GRACE-Poly v2.6 module contracts for the Task Status Center feature — backend API, service, and frontend Model/Component contracts with complexity anchors, decision memory, and cross-stack relations. @RATIONALE Contracts are the executable specification bridging spec.md ↔ code. Without them, agents optimize for token likelihood rather than spec compliance. @RELATION DEPENDS_ON -> [ReportModels] @RELATION DEPENDS_ON -> [TaskCenterModel] @RELATION DEPENDS_ON -> [TaskDrawer]
1. Complexity Inventory
| Module | Language | Complexity | Justification |
|---|---|---|---|
Reports.Summary (api route) |
Python | C4 | Multi-step: RBAC check, task loading, normalization, aggregation. Side effects: DB read. |
Reports.SummaryService (service) |
Python | C4 | Stateful: accesses TaskManager singleton, applies RBAC filtering. |
Reports.List (api route, enhanced) |
Python | C4 | Enhanced with RBAC row-filtering. Existing C3→C4. |
TaskCenterModel (model) |
TypeScript/Svelte | C4 | Stateful: 15+ atoms, WebSocket lifecycle, debounce, URL sync. Side effects: WS connect/disconnect, REST calls. |
SummaryPanel (component) |
Svelte 5 | C4 | WebSocket-reactive derived display. UX states: idle, loading, empty, disconnected. Side effects: click → filter. |
FilterBar (component) |
Svelte 5 | C3 | Multi-select inputs, search, time range. No side effects beyond model mutation. |
TaskList (component) |
Svelte 5 | C4 | Pagination, sort, click→drawer. UX states: loading, empty, error. |
TaskCenterReportsPage (page) |
Svelte 5 | C4 | Page-level orchestration: Model init, layout, UX state machine. |
ReportsTypes (types) |
TypeScript | C2 | DTO definitions only. |
2. Backend Contracts
2.1 Reports.SummaryRouter [C:4] [TYPE Module]
# backend/src/api/routes/reports.py (ADD to existing)
# #region Reports.SummaryRouter [C:4] [TYPE Module] [SEMANTICS api, reports, summary, task-status-center]
# @ingroup Reports
# @BRIEF Task summary endpoint — aggregated counts by type × status with RBAC filtering.
# @RELATION CALLS -> [Reports.SummaryService]
# @RELATION DEPENDS_ON -> [TaskSummary]
# @RELATION DEPENDS_ON -> [Auth.Dependencies]
# @RATIONALE Separate endpoint from list_reports (R1) — summary is a lightweight aggregation suitable for independent caching.
# @REJECTED Combining summary into list_reports header — rejected because it bloats paginated list responses and doubles server work.
# @REJECTED WebSocket-only summary delivery — rejected because initial page load needs guaranteed REST delivery (SC-001).
@router.get("/summary", response_model=TaskSummary)
async def get_task_summary(
current_user: User = Depends(get_current_user),
task_manager: TaskManager = Depends(get_task_manager),
clean_release_repo=Depends(get_clean_release_repository),
) -> TaskSummary:
"""
Get aggregated task summary by type and status.
Permission: tasks:READ (checked via get_current_user).
RBAC filtering: admin sees all, analyst sees own+system, viewer sees own only.
"""
...
# #endregion Reports.SummaryRouter
2.2 Reports.SummaryService [C:4] [TYPE Module]
# backend/src/services/reports/report_service.py (ADD method)
# #region Reports.SummaryService [C:4] [TYPE Module] [SEMANTICS service, summary, aggregation, rbac, task-status-center]
# @ingroup Reports
# @BRIEF Compute aggregated task summary with RBAC-aware filtering.
# @RELATION CALLS -> [TaskManager.get_all_tasks]
# @RELATION CALLS -> [Reports.Normalizer]
# @PRE task_manager is initialized and has tasks loaded.
# @PRE current_user is an authenticated User with tasks:READ permission.
# @POST Returns TaskSummary with counts filtered by user's role.
# @SIDE_EFFECT None (read-only aggregation).
# @RATIONALE RBAC filtering happens here (R4) — service layer, not API layer, keeps business logic testable.
# @REJECTED Filtering at TaskGraph level — rejected because graph is a data structure, not an authorization boundary.
def get_summary(self, current_user: User) -> TaskSummary:
"""
Compute aggregated task counts by (task_type × status).
RBAC rules:
- admin: all tasks
- analyst: own tasks + tasks with user_id=None (system)
- viewer: own tasks only
"""
...
# #endregion Reports.SummaryService
2.3 Reports.ListEnhanced [C:4] [TYPE Module]
# backend/src/api/routes/reports.py (MODIFY existing)
# #region Reports.ListEnhanced [C:4] [TYPE Module] [SEMANTICS api, reports, list, rbac, task-status-center]
# @ingroup Reports
# @BRIEF Enhanced report list endpoint with RBAC row-level filtering.
# @PRE current_user is authenticated.
# @POST Returns ReportCollection filtered by user role.
# @RATIONALE Enhancement of existing C3 endpoint to C4 — adds RBAC filtering per TSC-FR-009.
# @REJECTED Separate admin/user endpoints — rejected because single endpoint with role-based filtering avoids route duplication.
@router.get("", response_model=ReportCollection)
async def list_reports(
# ... existing params ...
current_user: User = Depends(get_current_user),
# ...
) -> ReportCollection:
# Unchanged signature, enhanced implementation:
# - task_filter applies RBAC before normalization
# - sort_by now accepts "created_at"
...
# #endregion Reports.ListEnhanced
2.4 RBAC Task Filter [C:3] [TYPE Function]
# backend/src/services/reports/report_service.py (ADD helper)
# #region Reports.RbacTaskFilter [C:3] [TYPE Function] [SEMANTICS service, rbac, filter, task-status-center]
# @ingroup Reports
# @BRIEF Filter tasks by user role for RBAC compliance.
# @PRE current_user is an authenticated User object.
# @POST Returns list of tasks visible to the user.
# @RATIONALE Extracted as a pure function for testability — no side effects, no service dependencies.
# @REJECTED Inline filtering in list_reports — rejected because it mixes authorization with query logic, making unit testing harder.
def _filter_tasks_by_rbac(tasks: list[Task], current_user: User) -> list[Task]:
"""
Filter tasks based on user role.
Rules (from TSC-FR-009):
- admin → all tasks
- analyst → own tasks + system tasks (user_id=None)
- viewer → own tasks only
"""
if any(role.name == "Admin" or getattr(role, "is_admin", False) for role in current_user.roles):
return tasks
if any(role.name == "analyst" for role in current_user.roles if not getattr(role, "is_admin", False)):
return [t for t in tasks if t.user_id == current_user.id or t.user_id is None]
# viewer
return [t for t in tasks if t.user_id == current_user.id]
# #endregion Reports.RbacTaskFilter
3. Frontend Contracts
3.1 TaskCenterModel [C:4] [TYPE Model]
// frontend/src/lib/models/TaskCenterModel.svelte.ts (NEW FILE)
// #region TaskCenterModel [C:4] [TYPE Model] [SEMANTICS model, task-center, summary, filtering, websocket, task-status-center]
// @BRIEF Screen Model for the Task Status Center — aggregates summary, task list, filtering, WebSocket subscription, and Task Drawer integration.
// @LAYER Store
// @PRE Auth token in localStorage, user authenticated via root +layout.svelte.
// @INVARIANT Active tasks (pending, running, awaiting_input) are always sorted before completed tasks.
// @INVARIANT visibleSummary excludes types in disabledTypes (admin) AND not in visibleTypes (operator).
// @INVARIANT 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 All public methods that mutate state trigger $derived recomputation.
// @POST WebSocket connection lifecycle is managed: connect on mount, disconnect on destroy.
// @SIDE_EFFECT Connects to /ws/task-events WebSocket. Calls fetch API. Updates browser URL via replaceState.
// @SIDE_EFFECT Opens TaskDrawer via taskDrawerStore.openDrawerForTask(). Reads/writes localStorage for visibleTypes.
// @RATIONALE Single model (R3) chosen because the 3 user stories are tightly coupled — summary, filtering, and list share the same task dataset. Premature decomposition would add indirection without benefit.
// @REJECTED Submodels from day one — rejected because estimated model size is ~250 lines, well below ADR-0010 400-line threshold.
// @REJECTED Global store — rejected because Task Center state is page-scoped, not cross-route.
// @REJECTED Polling-based refresh — rejected because spec requires real-time WebSocket updates (SC-002).
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([]); // admin-disabled types (from backend)
visibleTypes: TaskType[] = $state(loadVisibleTypes()); // operator preference (localStorage)
lastUpdated: Date | null = $state(null);
// === DERIVED ===
get filteredTasks(): TaskReport[] { ... } // filter + sort (active first)
get visibleSummary(): TaskSummary | null { ... } // exclude disabledTypes AND not in visibleTypes
get isEmpty(): boolean { ... }
get isFilteredEmpty(): boolean { ... }
get hasActiveFilters(): boolean { ... }
get totalPages(): number { ... }
get hasNextPage(): boolean { ... }
// === LIFECYCLE ===
async loadInitialData(initialFilters?: Partial<TaskCenterFilter>): Promise<void> { ... }
connectWebSocket(): void { ... }
disconnectWebSocket(): void { ... }
destroy(): void { ... }
// === ACTIONS ===
applyFilter(partial: Partial<TaskCenterFilter>): void { ... }
clearFilters(): void { ... }
selectTask(taskId: string): void { ... }
async refreshSummary(): Promise<void> { ... }
toggleTypeVisibility(type: TaskType): void { ... } // localStorage persist
// === PRIVATE METHODS ===
private _onWsMessage(event: MessageEvent): void { ... }
private _flushEventBuffer(): void { ... } // 100ms debounce
private _scheduleReconnect(): void { ... } // exponential backoff
private _syncFiltersToUrl(): void { ... } // replaceState, 300ms debounce
private _buildQueryString(): string { ... }
}
// #endregion TaskCenterModel
3.2 SummaryPanel [C:4] [TYPE Component]
<!-- frontend/src/lib/components/reports/SummaryPanel.svelte (NEW FILE) -->
<!-- #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 disconnected: last known counts visible, opacity reduced, "disconnected" badge -->
<!-- @UX_STATE reconnecting: last known counts visible, yellow pulsing indicator -->
<!-- @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). -->
<!-- @RATIONALE Cards are clickable per spec US2: "click on summary card → filter list by that type×status". -->
<script lang="ts">
import type { TaskSummary, TaskType, ReportStatus, ScreenState } from '$types/reports';
import { Card } from '$lib/ui';
interface Props {
summary: TaskSummary | null;
screenState: ScreenState;
onFilterByTypeAndStatus: (taskType: TaskType, status: ReportStatus) => void;
}
let { summary, screenState, onFilterByTypeAndStatus }: Props = $props();
</script>
{#if screenState === 'loading'}
<!-- skeleton -->
{:else if summary}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3">
{#each summary.by_type as typeSummary}
{#each STATUS_KEYS as status}
<Card padding="sm" onclick={() => onFilterByTypeAndStatus(typeSummary.task_type, status)}>
<!-- icon + count + status badge -->
</Card>
{/each}
{/each}
</div>
{/if}
<!-- #endregion SummaryPanel -->
3.3 TaskCenterReportsPage [C:4] [TYPE Page]
<!-- frontend/src/routes/reports/+page.svelte (REWRITE from existing) -->
<!-- #region TaskCenterReportsPage [C:4] [TYPE Page] [SEMANTICS page, reports, task-center, layout, task-status-center] -->
<!-- @BRIEF Task Status Center page — redesign of /reports with summary dashboard, real-time updates, and enhanced filtering. -->
<!-- @LAYER Page -->
<!-- @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. -->
<!-- @RATIONALE Thin render layer per ADR-0006 model-first pattern — all state and logic lives in TaskCenterModel. -->
<script lang="ts">
import { TaskCenterModel } from '$lib/models/TaskCenterModel.svelte.ts';
import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte';
import FilterBar from '$lib/components/reports/FilterBar.svelte';
import TaskList from '$lib/components/reports/TaskList.svelte';
import { PageHeader, Button } from '$lib/ui';
import { onMount } from 'svelte';
let { data } = $props();
const m = new TaskCenterModel();
onMount(() => {
m.loadInitialData(data.initialFilters);
});
// $effect for cleanup when component unmounts
$effect(() => {
return () => m.destroy();
});
</script>
<PageHeader title="Центр статусов">
<Button variant="ghost" onclick={() => m.refreshSummary()}>Обновить</Button>
</PageHeader>
{#if m.screenState === 'disconnected'}
<div class="bg-warning-light border border-warning-ring text-warning p-3 rounded-lg mb-4">
Соединение потеряно. Попытка переподключения...
<Button variant="secondary" onclick={() => m.connectWebSocket()}>Подключиться сейчас</Button>
</div>
{/if}
<SummaryPanel
summary={m.summary}
screenState={m.screenState}
onFilterByTypeAndStatus={(type, status) => m.applyFilter({ task_types: [type], statuses: [status] })}
/>
<FilterBar filters={m.filters} onApplyFilter={m.applyFilter} onClearFilters={m.clearFilters} />
<TaskList
tasks={m.filteredTasks}
screenState={m.screenState}
selectedTaskId={m.selectedTaskId}
page={m.page}
onSelectTask={m.selectTask}
onPageChange={(p) => { m.page = p; }}
/>
<!-- #endregion TaskCenterReportsPage -->
3.4 ReportsApiClient [C:3] [TYPE Module]
// frontend/src/lib/api/reports.ts (ENHANCE existing)
// #region ReportsApiClient [C:3] [TYPE Module] [SEMANTICS api, client, reports, typed, task-status-center]
// @BRIEF Typed API client for reports endpoints including the new summary endpoint.
// @LAYER API Client
// @RELATION CALLS -> [ApiModule.fetchApi]
// @RELATION CALLS -> [ApiModule.getTaskEventsWsUrl]
// @DATA_CONTRACT Input: ReportQuery → Output: ReportCollection
// @DATA_CONTRACT Input: (none) → Output: TaskSummary
// @PRE Auth token exists in localStorage.
// @POST All responses are typed (replaces generic <T>).
// EXISTING — add types
import type { TaskReport, ReportCollection, ReportDetailView, ReportQuery } from '$types/reports';
// NEW
import type { TaskSummary } from '$types/reports';
/** Fetch aggregated task summary. */
export async function getReportsSummary(): Promise<TaskSummary> {
const response = await api.fetchApi('/reports/summary');
return response.json();
}
// EXISTING — enhance with types
export async function getReports(options: Partial<ReportQuery>): Promise<ReportCollection> {
const query = buildReportQueryString(options);
const response = await api.fetchApi(`/reports${query}`);
return response.json();
}
export async function getReportDetail(reportId: string): Promise<ReportDetailView> {
const response = await api.fetchApi(`/reports/${reportId}`);
return response.json();
}
// #endregion ReportsApiClient
3.5 FilterBar [C:3] [TYPE Component]
<!-- frontend/src/lib/components/reports/FilterBar.svelte (NEW FILE — replaces inline filters in +page.svelte) -->
<!-- #region FilterBar [C:3] [TYPE Component] [SEMANTICS component, filter, search, multi-select, task-status-center] -->
<!-- @BRIEF Horizontal filter bar: task type multi-select, status multi-select, text search, time range, sort controls. -->
<!-- @LAYER UI Component -->
<!-- @UX_STATE default: all filters empty, showing all tasks -->
<!-- @UX_STATE filtered: active filters visible with "clear" button -->
<!-- @UX_FEEDBACK Filter change: immediate visual indicator on active filter badges -->
<!-- @UX_REACTIVITY Props: filters: TaskCenterFilter, onApplyFilter(), onClearFilters(). Pure presentation. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
<!-- @PRE filters is a valid TaskCenterFilter object. -->
<!-- @POST Calls onApplyFilter with partial filter updates. -->
<script lang="ts">
import { Button, Select, Input } from '$lib/ui';
import type { TaskCenterFilter, TaskType, ReportStatus } from '$types/reports';
// ... filter UI: multi-select dropdowns, search input, time range select, sort controls
</script>
<!-- #endregion FilterBar -->
3.6 TaskList [C:4] [TYPE Component]
<!-- frontend/src/lib/components/reports/TaskList.svelte (MODIFY existing — enhance with pagination, sort, active-first) -->
<!-- #region TaskList [C:4] [TYPE Component] [SEMANTICS component, list, task, pagination, sort, task-status-center] -->
<!-- @BRIEF Paginated task list with active-tasks-first sorting, status badges, and click-to-drawer. -->
<!-- @LAYER UI Component -->
<!-- @UX_STATE loading: skeleton rows -->
<!-- @UX_STATE empty: "No tasks" empty state with CTA buttons (inbox icon) -->
<!-- @UX_STATE filtered_empty: "No tasks matching filters" + clear filters button -->
<!-- @UX_STATE ready: task rows rendered -->
<!-- @UX_FEEDBACK Row click: opens TaskDrawer. Status change: animated status badge transition. -->
<!-- @UX_RECOVERY Empty: "Clear filters" button. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
<!-- @RELATION CALLS -> [TaskDrawer] -->
<!-- @PRE tasks is an array of TaskReport (may be empty). -->
<!-- @POST Clicking a task calls onSelectTask(taskId) which opens TaskDrawer. -->
<!-- #endregion TaskList -->
3.7 ReportsTypeProfiles [C:2] [TYPE Module]
// frontend/src/lib/components/reports/reportTypeProfiles.ts (MODIFY existing — add clean_release)
// #region ReportsTypeProfiles [C:2] [TYPE Module] [SEMANTICS profile, task-type, visual, mapping, task-status-center]
// @BRIEF Visual profiles for canonical task types — synchronized with backend type_profiles.py.
// @RATIONALE R8: Frontend profiles must match backend TaskType enum. Added clean_release entry.
// @RELATION DEPENDS_ON -> [Backend.type_profiles] via @DATA_CONTRACT
export function getReportTypeProfile(taskType: string): TypeProfile {
const profiles: Record<string, TypeProfile> = {
llm_verification: { key: 'llm_verification', label: 'LLM Verification', variant: 'info', icon: 'sparkles', fallback: false },
backup: { key: 'backup', label: 'Backup', variant: 'default', icon: 'archive', fallback: false },
migration: { key: 'migration', label: 'Migration', variant: 'default', icon: 'shuffle', fallback: false },
documentation: { key: 'documentation', label: 'Documentation', variant: 'default', icon: 'file-text', fallback: false },
clean_release: { key: 'clean_release', label: 'Clean Release', variant: 'success', icon: 'shield-check', fallback: false }, // ← NEW
};
return profiles[taskType] ?? profiles.unknown;
}
// #endregion ReportsTypeProfiles
4. Contract Traceability Matrix
| Spec FR | Contract(s) | Type | Complexity |
|---|---|---|---|
| TSC-FR-001 | SummaryPanel, Reports.SummaryRouter, Reports.SummaryService |
Component, API, Service | C4, C4, C4 |
| TSC-FR-002 | TaskCenterModel._onWsMessage, SummaryPanel |
Model, Component | C4, C4 |
| TSC-FR-003 | TaskList, Reports.ListEnhanced |
Component, API | C4, C4 |
| TSC-FR-004 | FilterBar, TaskCenterModel.applyFilter |
Component, Model | C3, C4 |
| TSC-FR-005 | TaskList (row template), TaskReport |
Component, Model | C4, C3 |
| TSC-FR-006 | TaskCenterModel.selectTask, TaskDrawer |
Model, Component | C4, existing |
| TSC-FR-007 | TaskList (awaiting_input indicator) |
Component | C4 |
| TSC-FR-008 | TaskCenterModel (reconnect logic), TaskCenterReportsPage |
Model, Page | C4, C4 |
| TSC-FR-009 | Reports.RbacTaskFilter, Reports.SummaryService |
Function, Service | C3, C4 |
| TSC-FR-010 | TaskCenterModel.filteredTasks ($derived) |
Model | C4 |
#endregion ModuleContracts