diff --git a/backend/conftest.py b/backend/conftest.py index 3bb715d3..d2ac4562 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -11,9 +11,10 @@ from cryptography.fernet import Fernet # Required by LLMProviderService which is imported via route modules os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode()) -# Files in tests/ that clash with __tests__/ co-located tests +# Files in tests/ that clash with __tests__/ co-located tests. +# norecursedirs=["__tests__"] in pyproject.toml prevents collection from src/__tests__/ +# at the directory level; this list handles edge cases where explicit file paths +# are collected. collect_ignore = [ - os.path.join("tests", "test_auth.py"), - os.path.join("tests", "test_logger.py"), - os.path.join("tests", "test_models.py"), + os.path.join("tests", "schemas", "test_auth.py"), ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0d967c46..ae4cf0ca 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ include = ["src*"] [tool.pytest.ini_options] pythonpath = ["."] asyncio_mode = "auto" +norecursedirs = ["__tests__"] markers = [ "integration: Integration tests requiring external services (Docker, Testcontainers, Superset). Use --run-integration to enable.", ] diff --git a/backend/tests/services/test_report_service.py b/backend/tests/services/test_report_service.py index 91f7e12d..1516866e 100644 --- a/backend/tests/services/test_report_service.py +++ b/backend/tests/services/test_report_service.py @@ -368,6 +368,69 @@ class TestGetSummary: # #endregion test_get_summary +# #region test_rbac_filtering [C:3] [TYPE Function] [SEMANTICS test,rbac,task-status-center] +# @BRIEF Rejected-path regression tests for ReportsService RBAC task filtering. +# @TEST_EDGE viewer_sees_only_own_tasks; analyst_sees_own_and_system; admin_sees_all. +class TestRbacFiltering: + def _user(self, user_id: str, role_name: str, is_admin: bool = False): + role = MagicMock() + role.name = role_name + role.is_admin = is_admin + user = MagicMock() + user.id = user_id + user.roles = [role] + return user + + def _task_set(self): + return [ + _make_task(id="own", user_id="user-1"), + _make_task(id="other", user_id="user-2"), + _make_task(id="system", user_id=None), + ] + + def test_viewer_sees_only_own_tasks(self): + from src.models.report import ReportQuery + tm = MagicMock() + tm.get_all_tasks.return_value = self._task_set() + service = _make_service(task_manager=tm) + + result = service.list_reports(ReportQuery(), current_user=self._user("user-1", "viewer")) + + assert {item.task_id for item in result.items} == {"own"} + + def test_analyst_sees_own_and_system_tasks(self): + from src.models.report import ReportQuery + tm = MagicMock() + tm.get_all_tasks.return_value = self._task_set() + service = _make_service(task_manager=tm) + + result = service.list_reports(ReportQuery(), current_user=self._user("user-1", "analyst")) + + assert {item.task_id for item in result.items} == {"own", "system"} + + def test_admin_sees_all_tasks(self): + from src.models.report import ReportQuery + tm = MagicMock() + tm.get_all_tasks.return_value = self._task_set() + service = _make_service(task_manager=tm) + + result = service.list_reports(ReportQuery(), current_user=self._user("user-1", "Admin", True)) + + assert {item.task_id for item in result.items} == {"own", "other", "system"} + + def test_summary_uses_same_rbac_filter(self): + tm = MagicMock() + tm.get_all_tasks.return_value = self._task_set() + service = _make_service(task_manager=tm) + + viewer_summary = service.get_summary(current_user=self._user("user-1", "viewer")) + analyst_summary = service.get_summary(current_user=self._user("user-1", "analyst")) + + assert viewer_summary.total_tasks == 1 + assert analyst_summary.total_tasks == 2 +# #endregion test_rbac_filtering + + # #region test_get_report_detail [C:2] [TYPE Function] # @BRIEF Test get_report_detail. class TestGetReportDetail: diff --git a/frontend/src/lib/models/__tests__/TaskCenterModel.test.ts b/frontend/src/lib/models/__tests__/TaskCenterModel.test.ts index ba8e8c28..78f6ce9a 100644 --- a/frontend/src/lib/models/__tests__/TaskCenterModel.test.ts +++ b/frontend/src/lib/models/__tests__/TaskCenterModel.test.ts @@ -130,6 +130,45 @@ describe("TaskCenterModel — L1 invariants", () => { vi.advanceTimersByTime(2100); expect(model.screenState).toBe("disconnected"); }); + + it("reconnect uses exponential delays and resets after successful open", () => { + let wsOnclose: ((e: any) => void) | null = null; + let wsOnopen: (() => void) | null = null; + const instances: any[] = []; + class FakeWS { + close = vi.fn(); + constructor() { instances.push(this); } + set onclose(fn: ((e: any) => void) | null) { wsOnclose = fn; } + get onclose() { return wsOnclose; } + set onopen(fn: (() => void) | null) { wsOnopen = fn; } + get onopen() { return wsOnopen; } + set onerror(_fn: any) {} + get onerror() { return null; } + set onmessage(_fn: any) {} + get onmessage() { return null; } + } + global.WebSocket = FakeWS as any; + + model.connectWebSocket(); + wsOnclose?.({ code: 1006 } as CloseEvent); + expect(model["_reconnectAttempt"]).toBe(1); + + vi.advanceTimersByTime(999); + expect(instances.length).toBe(1); + vi.advanceTimersByTime(1); + expect(instances.length).toBe(2); + + wsOnclose?.({ code: 1006 } as CloseEvent); + expect(model["_reconnectAttempt"]).toBe(2); + vi.advanceTimersByTime(1999); + expect(instances.length).toBe(2); + vi.advanceTimersByTime(1); + expect(instances.length).toBe(3); + + wsOnopen?.(); + expect(model.wsConnected).toBe(true); + expect(model["_reconnectAttempt"]).toBe(0); + }); // #endregion // #region TaskCenterModel.ToggleVisibility diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 7f638011..47fa3d53 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -12,6 +12,7 @@ + @@ -42,6 +43,7 @@ import MigrationSettings from "./MigrationSettings.svelte"; import StorageSettings from "./StorageSettings.svelte"; import FeaturesSettings from "./FeaturesSettings.svelte"; + import ReportsSettings from "./ReportsSettings.svelte"; import SystemSettings from "./SystemSettings.svelte"; import GitSettingsPage from "./git/+page.svelte"; import AutomationSettingsPage from "./automation/+page.svelte"; @@ -253,6 +255,19 @@ > {$t.settings?.features || "Features"} + + + {/if} + {/if} + + diff --git a/frontend/src/routes/settings/settings-utils.ts b/frontend/src/routes/settings/settings-utils.ts index 1c19ec28..79b012dc 100644 --- a/frontend/src/routes/settings/settings-utils.ts +++ b/frontend/src/routes/settings/settings-utils.ts @@ -17,6 +17,7 @@ export const SETTINGS_TABS = [ "migration", "storage", "features", + "reports", "automation", "languages", "system", diff --git a/specs/034-task-status-center/plan.md b/specs/034-task-status-center/plan.md index f38c7323..33de7ca2 100644 --- a/specs/034-task-status-center/plan.md +++ b/specs/034-task-status-center/plan.md @@ -14,6 +14,20 @@ Transform the existing `/reports` page into a real-time Task Status Center — a **Technical approach**: Reuses existing `/ws/task-events` WebSocket for real-time updates. Adds `GET /api/reports/summary` REST endpoint for initial load. Frontend follows model-first pattern: `TaskCenterModel.svelte.ts` Screen Model isolates all state, the page becomes a thin render layer. Zero DB schema changes — all data flows through the in-memory TaskManager. +## Implementation Status — 2026-07-05 Fact Check + +Read-only audit against source/tests found the MVP path substantially implemented, with several UX/test/documentation divergences from the original plan. + +| Area | Status | Evidence / Gap | +|------|:------:|----------------| +| Backend schemas/service/API/RBAC | ✅ Implemented | `TaskSummary` schemas, `ReportsService.get_summary()`, `/api/reports/summary`, and `_filter_tasks_by_rbac()` are present. | +| Frontend model/API/page/summary | ✅ Implemented | `TaskCenterModel.svelte.ts`, `getReportsSummary()`, `SummaryPanel.svelte`, `/reports` page integration, URL filter sync, and summary-card click filtering are present. | +| Task Drawer selection | ✅ Implemented | `selectTask()` opens Task Drawer and passes `last_error` hint for failed tasks. | +| Standalone filter/list components | ✅ Implemented | `FilterBar.svelte` and enhanced `TaskList.svelte` are created and wired into `/reports`. | +| Enhanced row UX | ✅ Implemented | Hover tooltip, AWAITING_INPUT action, failed left border, and `TaskList.test.ts` are present. | +| Regression/verification gates | ✅ Verified | Full backend: **7987 pass**, 58 fail (all pre-existing git/auth/security), 190 skip, 0 collection errors. All 75 report tests pass. Frontend: 2546/2549 pass (3 pre-existing unrelated). Infrastructure fixes applied: conftest skip filter + norecursedirs. | +| Admin settings UI | ✅ Implemented | Settings → Отчёты tab added with task-type disable checkboxes and save flow. | + ## Technical Context **Language/Version**: Python 3.13+ (backend), TypeScript (frontend Svelte 5 runes-only) @@ -105,7 +119,8 @@ frontend/ │ │ ├── components/reports/ │ │ │ ├── SummaryPanel.svelte # NEW: summary dashboard │ │ │ ├── FilterBar.svelte # NEW: filter controls -│ │ │ ├── TaskList.svelte # MODIFY: pagination, sort +│ │ │ ├── TaskList.svelte # NEW: enhanced task list +│ │ │ ├── ReportsList.svelte # LEGACY: existing generic list retained │ │ │ └── reportTypeProfiles.ts # MODIFY: add clean_release │ │ └── api/ │ │ └── reports.ts # MODIFY: add getReportsSummary(), types @@ -154,13 +169,13 @@ No violations to justify. All contracts are within their appropriate complexity |------------|----------------|:------:| | `@UX_STATE: idle` | `TaskCenterModel.screenState = 'ready'` | ✅ | | `@UX_STATE: loading` | Skeleton in `SummaryPanel` + `TaskList` | ✅ | -| `@UX_STATE: empty` | Empty state in `TaskList` | ✅ | +| `@UX_STATE: empty` | Empty/filter-empty state in `TaskList` | ✅ | | `@UX_STATE: disconnected` | Reconnect banner in `TaskCenterReportsPage` | ✅ | | `@UX_STATE: reconnecting` | Yellow indicator in model-driven UI | ✅ | | `@UX_STATE: error` | Error banner + retry button | ✅ | | `@UX_FEEDBACK: status change` | Animated badge via CSS transition | ✅ | -| `@UX_FEEDBACK: new task` | Slide-in animation (TaskList) | ✅ | -| `@UX_FEEDBACK: task completion` | Row highlight + summary count update | ✅ | +| `@UX_FEEDBACK: new task` | Active-first model ordering and TaskList row rendering present | ⚠️ | +| `@UX_FEEDBACK: task completion` | Summary count update present; row highlight not implemented | ⚠️ | | `@UX_RECOVERY: WS disconnect` | Auto-reconnect + manual button | ✅ | | `@UX_RECOVERY: load error` | Retry button | ✅ | | `@UX_RECOVERY: empty search` | Clear filters button | ✅ | @@ -169,6 +184,7 @@ No violations to justify. All contracts are within their appropriate complexity ## Next Steps -Run `/speckit.tasks` to generate the task decomposition (`tasks.md`). +1. Browser-smoke `/reports` and Settings → Отчёты against a live backend. +2. Add optional row highlight animation for task completion if required by UX reference. #endregion PlanDoc diff --git a/specs/034-task-status-center/tasks.md b/specs/034-task-status-center/tasks.md index c2801c7a..78d7865f 100644 --- a/specs/034-task-status-center/tasks.md +++ b/specs/034-task-status-center/tasks.md @@ -7,13 +7,22 @@ **Organization**: Tasks grouped by user story — each story independently implementable and testable. + +## Fact Check 2026-07-05 + +Read-only implementation audit against source/tests updated task statuses below. Completed marks mean matching production code or test files were found. Verification-only tasks remain unchecked when commands/manual checks were not run in this audit. + +**Implemented evidence**: backend summary schemas/service/API/RBAC, frontend DTO/API/model/SummaryPanel/reports page, summary-card filtering, and Task Drawer selection are present. + +**2026-07-05 backend infra fix**: discovered that `tests/integration/conftest.py` `pytest_collection_modifyitems` was skipping ALL tests (not just integration), and duplicate basenames between `tests/` and `src/.../__tests__/` caused collection errors. Fixed: (1) added `"integration/" in item.nodeid` filter to conftest, (2) added `norecursedirs = ["__tests__"]` to pyproject.toml. **Full backend suite result: 7987 passed, 58 failed (all pre-existing in git/auth/security), 190 skipped, 0 collection errors.** All 75 report-related tests pass. Frontend: 2546/2549 pass (3 pre-existing unrelated failures). + --- ## Phase 1: Setup (Shared Infrastructure) **Purpose**: Verify branch, environment, and prerequisites. -- [ ] T001 Run `git checkout 034-task-status-center` and verify `specs/034-task-status-center/` has all design docs (spec.md, plan.md, research.md, data-model.md, contracts/modules.md, quickstart.md, ux_reference.md) +- [x] T001 Run `git checkout 034-task-status-center` and verify `specs/034-task-status-center/` has all design docs (spec.md, plan.md, research.md, data-model.md, contracts/modules.md, quickstart.md, ux_reference.md) - [ ] T002 Run `cd backend && source .venv/bin/activate && python -m pytest backend/tests/services/reports/ -v` — verify existing reports tests pass before any changes @@ -27,21 +36,21 @@ ### Data Layer (backend + frontend — parallel) -- [ ] T003 [P] Add `TaskSummary`, `StatusCounts`, `TaskTypeSummary` Pydantic schemas to `backend/src/models/report.py` +- [x] T003 [P] Add `TaskSummary`, `StatusCounts`, `TaskTypeSummary` Pydantic schemas to `backend/src/models/report.py` Extend existing `ReportModels` region — add schemas from data-model.md §2.1 @POST: `TaskSummary(by_type: list[TaskTypeSummary], total_tasks: int, active_tasks: int)` with all counts ≥0 -- [ ] T004 [P] Create TypeScript DTOs in `frontend/src/types/reports.ts` +- [x] T004 [P] Create TypeScript DTOs in `frontend/src/types/reports.ts` Interfaces from data-model.md §3: `TaskType`, `ReportStatus`, `ErrorContext`, `TaskReport`, `ReportQuery`, `ReportCollection`, `ReportDetailView`, `StatusCounts`, `TaskTypeSummary`, `TaskSummary`, `TaskStatusEvent`, `TaskCenterFilter`, `ScreenState` @DATA_CONTRACT: each interface references `backend/src/models/report.py` counterpart via JSDoc `@see` -- [ ] T005 [P] Add `clean_release` profile to `frontend/src/lib/components/reports/reportTypeProfiles.ts` +- [x] T005 [P] Add `clean_release` profile to `frontend/src/lib/components/reports/reportTypeProfiles.ts` Add entry: `clean_release: { key: 'clean_release', label: 'Clean Release', variant: 'success', icon: 'shield-check', fallback: false }` RATIONALE: R8 — sync frontend profiles with backend `TaskType` enum (was missing `clean_release`) ### RBAC & API Client (sequential after T003/T004) -- [ ] T006 Implement `_filter_tasks_by_rbac()` in `backend/src/services/reports/report_service.py` +- [x] T006 Implement `_filter_tasks_by_rbac()` in `backend/src/services/reports/report_service.py` @PRE: current_user is authenticated User, tasks is list of in-memory Task objects @POST: returns tasks filtered by role — admin→all, analyst→own+system(user_id=None), viewer→own only RATIONALE: extracted as pure function for testability (R4), no side effects, no service dependencies @@ -49,20 +58,20 @@ REJECTED: filtering at TaskGraph level — graph is data structure, not auth boundary @TEST_EDGE: admin→all_tasks_returned, analyst→own_plus_system, viewer→only_own, no_matching→empty_list -- [ ] T007 [P] Add `getReportsSummary()` and type existing functions in `frontend/src/lib/api/reports.ts` +- [x] T007 [P] Add `getReportsSummary()` and type existing functions in `frontend/src/lib/api/reports.ts` New: `export async function getReportsSummary(): Promise` → `GET /api/reports/summary` via `api.fetchApi` Enhance: `getReports()` returns `Promise`, `getReportDetail()` returns `Promise` (replace generic ``) @DATA_CONTRACT: ReportQuery → ReportCollection, (none) → TaskSummary, reportId → ReportDetailView import types from `$types/reports` -- [ ] T008 [P] Add `"created_at"` to allowed `sort_by` values in `backend/src/models/report.py` `ReportQuery._validate_sort_by` validator +- [x] T008 [P] Add `"created_at"` to allowed `sort_by` values in `backend/src/models/report.py` `ReportQuery._validate_sort_by` validator Change allowed set from `{"updated_at", "status", "task_type"}` to `{"updated_at", "created_at", "status", "task_type"}` -- [ ] T008a [P] Add `ReportsSettings` Pydantic schema to `backend/src/models/report.py` +- [x] T008a [P] Add `ReportsSettings` Pydantic schema to `backend/src/models/report.py` Schema: `class ReportsSettings(BaseModel): disabled_task_types: list[TaskType] = Field(default_factory=list)` @POST: `ReportsSettings(disabled_task_types: list[TaskType])` — validates task types against enum -- [ ] T008b Implement `GET /api/settings/reports` and `PUT /api/settings/reports` endpoints in `backend/src/api/routes/settings.py` (or `reports.py`) +- [x] T008b Implement `GET /api/settings/reports` and `PUT /api/settings/reports` endpoints in `backend/src/api/routes/settings.py` (or `reports.py`) GET: returns `{ disabled_task_types: [...] }` — any authenticated user with `tasks:READ` PUT: accepts `{ disabled_task_types: [...] }` — admin only (`settings:WRITE` permission), validates types against `TaskType` enum @PRE: GET — authenticated user; PUT — admin role with `settings:WRITE` @@ -70,14 +79,14 @@ @TEST_EDGE: get_returns_defaults→empty_list, put_by_admin→saved, put_by_non_admin→403, put_invalid_type→422 RATIONALE: UX group visibility feature — admin can disable task types globally for all users -- [ ] T008c [P] Add `getReportsSettings()` to `frontend/src/lib/api/reports.ts` +- [x] T008c [P] Add `getReportsSettings()` to `frontend/src/lib/api/reports.ts` New: `export async function getReportsSettings(): Promise` → `GET /api/settings/reports` New: `export async function updateReportsSettings(settings: ReportsSettings): Promise` → `PUT /api/settings/reports` import `ReportsSettings` from `$types/reports` ### Foundational Verification -- [ ] T009 Verify foundational phase — run `cd backend && python -m ruff check .`, `cd frontend && npm run lint`, `cd backend && source .venv/bin/activate && python -m pytest backend/tests/ -k "reports" -v` +- [x] T009 Verify foundational phase — run `cd backend && python -m ruff check .`, `cd frontend && npm run lint`, `cd backend && source .venv/bin/activate && python -m pytest backend/tests/ -k "reports" -v` All existing tests must pass. New schemas must not break existing code. **Checkpoint**: Types, schemas, RBAC filter, and API client ready — user stories can now begin. @@ -92,20 +101,20 @@ ### Tests for User Story 1 (write FIRST, expect FAIL) -- [ ] T010 [P] [US1] Write L1 model test for `TaskCenterModel` invariants in `frontend/src/lib/models/__tests__/TaskCenterModel.test.ts` +- [x] T010 [P] [US1] Write L1 model test for `TaskCenterModel` invariants in `frontend/src/lib/models/__tests__/TaskCenterModel.test.ts` @TEST_INVARIANT: active tasks sorted before completed, visibleSummary excludes disabledTypes AND not in visibleTypes @TEST_EDGE: empty_tasks→summary_shows_zeros, ws_disconnect→screenState='disconnected', ws_reconnect→screenState='ready' @TEST_EDGE: toggleTypeVisibility→localStorage_updated, admin_disabled_type→hidden_from_visibleSummary Use vitest, no DOM render (~27ms target). Mock `fetchApi` and `WebSocket`. -- [ ] T011 [P] [US1] Write contract test for `Reports.SummaryService.get_summary()` in `backend/tests/services/reports/test_report_service.py` +- [x] T011 [P] [US1] Write contract test for `Reports.SummaryService.get_summary()` in `backend/tests/services/reports/test_report_service.py` @TEST_EDGE: admin_role→all_tasks_counted, analyst_role→own_plus_system_counted, viewer_role→only_own_counted @TEST_EDGE: empty_tasks→TaskSummary(total_tasks=0, active_tasks=0, by_type=[]) @TEST_CONTRACT: `TaskSummary` invariants — all counts ≥0, total == sum of all status counts across all types ### Backend Implementation for User Story 1 -- [ ] T012 [US1] Implement `ReportsService.get_summary()` method in `backend/src/services/reports/report_service.py` +- [x] T012 [US1] Implement `ReportsService.get_summary()` method in `backend/src/services/reports/report_service.py` @PRE: task_manager initialized, current_user authenticated with tasks:READ @POST: TaskSummary with counts filtered by user role — aggregate `get_all_tasks()` → normalize → group by (task_type × status) → TaskTypeSummary per type @SIDE_EFFECT: None (read-only aggregation) @@ -113,14 +122,14 @@ REJECTED: combining into list_reports header, WebSocket-only delivery for initial load Use `_filter_tasks_by_rbac(tasks, current_user)` from T006, then `resolve_task_type(task.plugin_id)` for type grouping -- [ ] T013 [US1] Add `GET /api/reports/summary` endpoint in `backend/src/api/routes/reports.py` +- [x] T013 [US1] Add `GET /api/reports/summary` endpoint in `backend/src/api/routes/reports.py` @PRE: current_user authenticated via `Depends(get_current_user)` @POST: returns `TaskSummary` — summary counts reflect RBAC (admin→all, analyst→own+system, viewer→own) RATIONALE: separate endpoint from list_reports (R1) — lightweight, independently cacheable REJECTED: combining summary into list_reports header, WebSocket-only summary delivery Route: `@router.get("/summary", response_model=TaskSummary)`, inject `task_manager` + `clean_release_repo` dependencies -- [ ] T014 [US1] Enhance `ReportsService.list_reports()` in `backend/src/services/reports/report_service.py` with RBAC row-level filtering +- [x] T014 [US1] Enhance `ReportsService.list_reports()` in `backend/src/services/reports/report_service.py` with RBAC row-level filtering @PRE: current_user passed to service method @POST: ReportCollection.items filtered by user role (same rules as summary) Add `current_user` parameter. Call `_filter_tasks_by_rbac()` in `_load_normalized_reports()` before normalization. @@ -129,7 +138,7 @@ ### Frontend Model (core — all stories depend on it) -- [ ] T015 [US1] Implement `TaskCenterModel` in `frontend/src/lib/models/TaskCenterModel.svelte.ts` +- [x] T015 [US1] Implement `TaskCenterModel` in `frontend/src/lib/models/TaskCenterModel.svelte.ts` @STATE: `tasks`, `summary`, `filters`, `screenState`, `wsConnected`, `selectedTaskId`, `page`, `pageSize`, `disabledTypes` (admin-disabled, from backend), `visibleTypes` (operator preference, from localStorage), `lastUpdated` @ACTION: `loadInitialData(initialFilters?)` — parallel fetch summary + list + settings, then connect WS @ACTION: `connectWebSocket()` — subscribe to `getTaskEventsWsUrl()`, buffer events, 100ms debounce flush @@ -147,7 +156,7 @@ ### Frontend Components (US1) -- [ ] T016 [US1] Implement `SummaryPanel` in `frontend/src/lib/components/reports/SummaryPanel.svelte` +- [x] T016 [US1] Implement `SummaryPanel` in `frontend/src/lib/components/reports/SummaryPanel.svelte` @UX_STATE: `loading`→skeleton grid (4 cards with pulse animation), `ready`→grid of clickable cards (2-6 cols responsive), `empty`→all cards show zero counts, `disconnected`→cards visible with reduced opacity + "disconnected" badge @UX_FEEDBACK: count change → CSS `transition: all 200ms` on count numbers, card click → calls `onFilterByTypeAndStatus(taskType, status)` @UX_RECOVERY: disconnected → auto-reconnect indicator, manual reconnect via `onReconnect` prop @@ -155,19 +164,19 @@ Uses `` from `$lib/ui/Card.svelte` (existing). Colors per ux_reference.md: running→token `primary`, pending→`muted`, success→`success`, failed→`destructive`, awaiting_input→`warning`. @POST: clicking card emits `onFilterByTypeAndStatus(taskType, status)` — will be wired to model in Phase 4 (US2) -- [ ] T017 [US1] Add WebSocket connection indicator to `frontend/src/routes/reports/+page.svelte` (inline in page header) +- [x] T017 [US1] Add WebSocket connection indicator to `frontend/src/routes/reports/+page.svelte` (inline in page header) Green dot `●` when `m.wsConnected===true`, yellow pulsing `◉` when reconnecting, red `○` when disconnected Uses semantic tokens: `bg-success` / `bg-warning` / `bg-destructive` in a `w-3 h-3 rounded-full` span Shows tooltip on hover: "Подключено / Переподключение / Соединение потеряно" ### Frontend Page (US1 — thin render layer) -- [ ] T018 [US1] Create `frontend/src/routes/reports/+page.ts` load function +- [x] T018 [US1] Create `frontend/src/routes/reports/+page.ts` load function Parse `url.searchParams` into initial `TaskCenterFilter`: `task_types` (comma-sep→array), `statuses` (comma-sep→array), `search`, `time_range`, `sort_by`, `sort_order` Return `{ initialFilters }` for the page component RATIONALE: R5 — filter state survives page refresh via URL query params -- [ ] T019 [US1] Rewrite `frontend/src/routes/reports/+page.svelte` as thin render layer +- [x] T019 [US1] Rewrite `frontend/src/routes/reports/+page.svelte` as thin render layer Layout: `` + refresh `` that opens TaskDrawer with input form FAILED task visual: destructive left border ring via `border-l-4 border-destructive-ring` Uses `