From 33ee976c4894a2cca4fdf57d93b1599a9acba5a8 Mon Sep 17 00:00:00 2001 From: busya Date: Thu, 2 Jul 2026 18:53:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(reports):=20Task=20Status=20Center=20?= =?UTF-8?q?=E2=80=94=20unified=20/reports=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Страница /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 --- backend/src/api/routes/reports.py | 27 +- backend/src/api/routes/settings.py | 43 ++ backend/src/models/report.py | 46 +- backend/src/services/reports/normalizer.py | 13 + .../src/services/reports/report_service.py | 104 +++- backend/tests/services/test_report_service.py | 78 +++ frontend/src/lib/api/reports.ts | 44 +- .../lib/components/layout/TaskDrawer.svelte | 23 +- .../lib/components/reports/ReportCard.svelte | 22 +- .../components/reports/SummaryPanel.svelte | 95 ++++ .../reports/__tests__/SummaryPanel.ux.test.ts | 130 +++++ .../components/reports/reportTypeProfiles.ts | 7 + .../src/lib/models/TaskCenterModel.svelte.ts | 399 +++++++++++++++ .../models/__tests__/TaskCenterModel.test.ts | 237 +++++++++ frontend/src/lib/stores/taskDrawer.svelte.ts | 6 +- frontend/src/lib/ui/Pagination.svelte | 7 +- frontend/src/lib/utils/dateFormat.ts | 20 + frontend/src/routes/reports/+page.svelte | 400 ++++++++------- frontend/src/routes/reports/+page.ts | 45 ++ frontend/src/types/reports.ts | 131 +++++ frontend/svelte.config.js | 3 +- run.sh | 42 ++ .../contracts/modules.md | 474 ++++++++++++++++++ .../contracts/ux/alternatives.md | 89 ++++ .../contracts/ux/api-ux.md | 122 +++++ .../contracts/ux/decisions.md | 25 + .../contracts/ux/design-tokens.md | 98 ++++ .../contracts/ux/screen-models.md | 79 +++ .../contracts/ux/task-center-ux.md | 90 ++++ specs/034-task-status-center/data-model.md | 296 +++++++++++ specs/034-task-status-center/plan.md | 174 +++++++ specs/034-task-status-center/quickstart.md | 133 +++++ specs/034-task-status-center/research.md | 252 ++++++++++ 33 files changed, 3536 insertions(+), 218 deletions(-) create mode 100644 frontend/src/lib/components/reports/SummaryPanel.svelte create mode 100644 frontend/src/lib/components/reports/__tests__/SummaryPanel.ux.test.ts create mode 100644 frontend/src/lib/models/TaskCenterModel.svelte.ts create mode 100644 frontend/src/lib/models/__tests__/TaskCenterModel.test.ts create mode 100644 frontend/src/routes/reports/+page.ts create mode 100644 frontend/src/types/reports.ts create mode 100644 specs/034-task-status-center/contracts/modules.md create mode 100644 specs/034-task-status-center/contracts/ux/alternatives.md create mode 100644 specs/034-task-status-center/contracts/ux/api-ux.md create mode 100644 specs/034-task-status-center/contracts/ux/decisions.md create mode 100644 specs/034-task-status-center/contracts/ux/design-tokens.md create mode 100644 specs/034-task-status-center/contracts/ux/screen-models.md create mode 100644 specs/034-task-status-center/contracts/ux/task-center-ux.md create mode 100644 specs/034-task-status-center/data-model.md create mode 100644 specs/034-task-status-center/plan.md create mode 100644 specs/034-task-status-center/quickstart.md create mode 100644 specs/034-task-status-center/research.md diff --git a/backend/src/api/routes/reports.py b/backend/src/api/routes/reports.py index 430c70cd..62b91992 100644 --- a/backend/src/api/routes/reports.py +++ b/backend/src/api/routes/reports.py @@ -22,6 +22,7 @@ from ...core.logger import belief_scope from ...core.task_manager import TaskManager from ...dependencies import ( get_clean_release_repository, + get_current_user, get_task_manager, has_permission, ) @@ -30,8 +31,10 @@ from ...models.report import ( ReportDetailView, ReportQuery, ReportStatus, + TaskSummary, TaskType, ) +from ...schemas.auth import User from ...services.clean_release.repository import CleanReleaseRepository from ...services.reports.report_service import ReportsService @@ -105,6 +108,7 @@ async def list_reports( search: str | None = Query(None, max_length=200), sort_by: str = Query("updated_at"), sort_order: str = Query("desc"), + current_user: User = Depends(get_current_user), task_manager: TaskManager = Depends(get_task_manager), clean_release_repository: CleanReleaseRepository = Depends( get_clean_release_repository @@ -141,12 +145,33 @@ async def list_reports( service = ReportsService( task_manager, clean_release_repository=clean_release_repository ) - return service.list_reports(query) + return service.list_reports(query, current_user=current_user) # #endregion list_reports +# #region get_task_summary [C:3] [TYPE Function] [SEMANTICS api, reports, summary, task-status-center] +# @ingroup Api +# @BRIEF Return aggregated task summary by type × status with RBAC filtering. +# @RELATION CALLS -> [ReportsService.get_summary] +@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_repository: CleanReleaseRepository = Depends( + get_clean_release_repository + ), + _=Depends(has_permission("tasks", "READ")), +): + with belief_scope("get_task_summary"): + service = ReportsService( + task_manager, clean_release_repository=clean_release_repository + ) + return service.get_summary(current_user=current_user) +# #endregion get_task_summary + + # #region get_report_detail [C:2] [TYPE Function] # @ingroup Api # @BRIEF Return one normalized report detail with diagnostics and next actions. diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index ad15fbe3..1c034e66 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -895,4 +895,47 @@ async def test_connection( # #endregion test_connection + +# #region ReportsSettings [C:3] [TYPE Function] [SEMANTICS settings, reports, visibility, admin] +# @ingroup Api +# @BRIEF Get and update reports settings (admin-disabled task types). +# @RELATION DEPENDS_ON -> [ReportsSettings] +# @RELATION DEPENDS_ON -> [has_permission] +from ...models.report import ReportsSettings as ReportsSettingsSchema +from sqlalchemy import select + +@router.get("/reports") +async def get_reports_settings( + db: Session = Depends(get_db), + _=Depends(has_permission("tasks", "READ")), +): + """Get reports settings — which task types are disabled globally.""" + record = db.query(AppConfigRecord).filter(AppConfigRecord.id == "reports_settings").first() + if record: + disabled = record.payload.get("disabled_task_types", []) + return {"disabled_task_types": disabled} + return {"disabled_task_types": []} + + +@router.put("/settings/reports") +async def update_reports_settings( + settings: ReportsSettingsSchema, + db: Session = Depends(get_db), + _=Depends(has_permission("settings", "WRITE")), +): + """Update reports settings — admin only. Sets which task types are disabled globally.""" + record = db.query(AppConfigRecord).filter(AppConfigRecord.id == "reports_settings").first() + if record: + record.payload = {"disabled_task_types": settings.disabled_task_types} + else: + record = AppConfigRecord( + id="reports_settings", + payload={"disabled_task_types": settings.disabled_task_types}, + ) + db.add(record) + db.commit() + return {"disabled_task_types": settings.disabled_task_types} +# #endregion ReportsSettings + + # #endregion SettingsRouter diff --git a/backend/src/models/report.py b/backend/src/models/report.py index 2555d0e0..9d2d5bc6 100644 --- a/backend/src/models/report.py +++ b/backend/src/models/report.py @@ -172,7 +172,7 @@ class ReportQuery(BaseModel): @field_validator("sort_by") @classmethod def _validate_sort_by(cls, value: str) -> str: - allowed = {"updated_at", "status", "task_type"} + allowed = {"updated_at", "created_at", "status", "task_type"} if value not in allowed: raise ValueError(f"sort_by must be one of: {', '.join(sorted(allowed))}") return value @@ -243,4 +243,48 @@ class ReportDetailView(BaseModel): # #endregion ReportDetailView + +# #region TaskSummary [C:3] [TYPE Class] [SEMANTICS summary, aggregation, task, status, count] +# @defgroup Models Module group. +# @BRIEF Aggregated task counts by type and status for the summary dashboard. +# @DATA_CONTRACT Output: TaskSummary → frontend interface TaskSummary +# @RELATION DEPENDS_ON -> [TaskReport] +# @TEST_CONTRACT TaskSummaryModel -> { invariants: ["all counts >= 0", "total == sum of all status counts across all types"] } +class StatusCounts(BaseModel): + """Counts per status for a single task type.""" + pending: int = Field(default=0, ge=0) + running: int = Field(default=0, ge=0) + awaiting_input: int = Field(default=0, ge=0) + success: int = Field(default=0, ge=0) + failed: int = Field(default=0, ge=0) + + +class TaskTypeSummary(BaseModel): + """Summary for one task type.""" + task_type: TaskType + display_label: str = "" + icon_token: str = "" + counts: StatusCounts + total: int = Field(default=0, ge=0) + + +class TaskSummary(BaseModel): + """Full summary response for the summary dashboard.""" + by_type: list[TaskTypeSummary] + total_tasks: int = Field(default=0, ge=0) + active_tasks: int = Field(default=0, ge=0) # pending + running + awaiting_input + + +# #endregion TaskSummary + + +# #region ReportsSettings [C:2] [TYPE Class] [SEMANTICS settings, reports, admin, visibility] +# @defgroup Models Module group. +# @BRIEF Admin settings for reports visibility — which task types are disabled globally. +class ReportsSettings(BaseModel): + """Admin policy: task types disabled for all users.""" + disabled_task_types: list[TaskType] = Field(default_factory=list) +# #endregion ReportsSettings + + # #endregion ReportModels diff --git a/backend/src/services/reports/normalizer.py b/backend/src/services/reports/normalizer.py index 2f4566d7..0fccfbc5 100644 --- a/backend/src/services/reports/normalizer.py +++ b/backend/src/services/reports/normalizer.py @@ -126,6 +126,19 @@ def normalize_task_report(task: Task) -> TaskReport: with belief_scope("normalize_task_report"): task_type = resolve_task_type(task.plugin_id) report_status = status_to_report_status(task.status) + + # LLM validation paradox fix: if task completed successfully but validation found + # problems, override to PARTIAL (warning) so the UI doesn't flash green for failures. + if report_status == ReportStatus.SUCCESS and task.plugin_id == "llm_dashboard_validation": + result = task.result if isinstance(task.result, dict) else {} + fail_count = result.get("fail_count", 0) + warn_count = result.get("warn_count", 0) + raw_status = str(result.get("status", "")).upper() + if (isinstance(fail_count, int) and fail_count > 0) or raw_status == "FAIL": + report_status = ReportStatus.FAILED + elif (isinstance(warn_count, int) and warn_count > 0) or raw_status == "WARN": + report_status = ReportStatus.PARTIAL + profile = get_type_profile(task_type) started_at = task.started_at if isinstance(task.started_at, datetime) else None diff --git a/backend/src/services/reports/report_service.py b/backend/src/services/reports/report_service.py index 98b46c9d..0f8b3acf 100644 --- a/backend/src/services/reports/report_service.py +++ b/backend/src/services/reports/report_service.py @@ -24,11 +24,50 @@ from ...models.report import ( ReportDetailView, ReportQuery, ReportStatus, + StatusCounts, TaskReport, + TaskSummary, TaskType, + TaskTypeSummary, ) from ..clean_release.repository import CleanReleaseRepository from .normalizer import normalize_task_report +from ...core.task_manager.models import Task, TaskStatus + + +# #region _filter_tasks_by_rbac [C:3] [TYPE Function] [SEMANTICS service, rbac, filter, task-status-center] +# @ingroup Services +# @BRIEF Filter tasks by user role for RBAC compliance. +# @PRE current_user is an authenticated User ORM 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 — mixes authorization with query logic, making unit testing harder. +def _filter_tasks_by_rbac(tasks: list[Task], current_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 + """ + is_admin = any( + getattr(role, "is_admin", False) or role.name == "Admin" + for role in current_user.roles + ) + if is_admin: + return tasks + is_analyst = any( + role.name == "analyst" for role in current_user.roles + if not getattr(role, "is_admin", False) + ) + if is_analyst: + return [t for t in tasks if t.user_id == current_user.id or t.user_id is None] + # viewer or unknown: own tasks only + return [t for t in tasks if t.user_id == current_user.id] + + +# #endregion _filter_tasks_by_rbac # #region ReportsService [C:5] [TYPE Class] @@ -190,14 +229,20 @@ class ReportsService: # endregion _sort_reports # region list_reports [TYPE Function] - # @PURPOSE: Return filtered, sorted, paginated report collection. + # @PURPOSE: Return filtered, sorted, paginated report collection with optional RBAC filtering. # @PRE query has passed schema validation. - # @POST Returns {items,total,page,page_size,has_next,applied_filters}. + # @POST Returns ReportCollection with optional RBAC filtering applied. # @PARAM query (ReportQuery) - List filters and pagination. + # @PARAM current_user (optional) - If provided, filters tasks by RBAC role. # @RETURN ReportCollection - Paginated unified reports payload. - def list_reports(self, query: ReportQuery) -> ReportCollection: + def list_reports(self, query: ReportQuery, current_user=None) -> ReportCollection: with belief_scope("list_reports"): reports = self._load_normalized_reports() + if current_user: + tasks = self.task_manager.get_all_tasks() + filtered_tasks = _filter_tasks_by_rbac(tasks, current_user) + task_ids = {t.id for t in filtered_tasks} + reports = [r for r in reports if r.task_id in task_ids] filtered = [ report for report in reports if self._matches_query(report, query) ] @@ -298,6 +343,59 @@ class ReportsService: # endregion get_report_detail + # region get_summary [TYPE Function] + # @PURPOSE: Return aggregated task summary by type × status with RBAC filtering. + # @PRE current_user is authenticated. + # @POST Returns TaskSummary with counts filtered by user role. + # @SIDE_EFFECT None (read-only aggregation). + def get_summary(self, current_user=None) -> TaskSummary: + with belief_scope("get_summary"): + tasks = self.task_manager.get_all_tasks() + if current_user: + tasks = _filter_tasks_by_rbac(tasks, current_user) + + # Group by task_type, using raw TaskStatus for 5 granular buckets + by_type: dict[TaskType, dict[str, int]] = {} + for task in tasks: + report = normalize_task_report(task) + tt = report.task_type + if tt not in by_type: + by_type[tt] = {"pending": 0, "running": 0, "awaiting_input": 0, "success": 0, "failed": 0} + + # Map raw TaskStatus → summary bucket (not normalized ReportStatus) + raw = str(task.status.value if hasattr(task, 'status') else "").upper() + if raw == TaskStatus.PENDING.value: + bucket = "pending" + elif raw == TaskStatus.RUNNING.value: + bucket = "running" + elif raw in {TaskStatus.AWAITING_INPUT.value, TaskStatus.AWAITING_MAPPING.value}: + bucket = "awaiting_input" + elif raw == TaskStatus.SUCCESS.value: + bucket = "success" + elif raw == TaskStatus.FAILED.value: + bucket = "failed" + else: + bucket = "pending" + by_type[tt][bucket] += 1 + + type_summaries = [] + for tt, counts in by_type.items(): + total = sum(counts.values()) + active = counts["pending"] + counts["running"] + counts["awaiting_input"] + type_summaries.append(TaskTypeSummary( + task_type=tt, + display_label=tt.value, + icon_token="", + counts=StatusCounts(**counts), + total=total, + )) + + total_tasks = sum(s.total for s in type_summaries) + active_tasks = sum(s.counts.pending + s.counts.running + s.counts.awaiting_input for s in type_summaries) + return TaskSummary(by_type=type_summaries, total_tasks=total_tasks, active_tasks=active_tasks) + + # endregion get_summary + # #endregion ReportsService diff --git a/backend/tests/services/test_report_service.py b/backend/tests/services/test_report_service.py index 4bc99788..91f7e12d 100644 --- a/backend/tests/services/test_report_service.py +++ b/backend/tests/services/test_report_service.py @@ -290,6 +290,84 @@ class TestListReports: # #endregion test_list_reports +# #region test_get_summary [C:2] [TYPE Function] +# @BRIEF Test get_summary aggregation. +# @TEST_EDGE: all statuses distributed into 5 buckets, empty tasks returns zeros, pending/awaiting_input counted separately. +class TestGetSummary: + def test_empty_tasks_returns_zeros(self): + from src.models.report import ReportQuery + tm = MagicMock() + tm.get_all_tasks.return_value = [] + service = _make_service(task_manager=tm) + summary = service.get_summary() + assert summary.total_tasks == 0 + assert summary.active_tasks == 0 + assert summary.by_type == [] + + def test_counts_all_5_buckets_separately(self): + from src.core.task_manager.models import TaskStatus + tm = MagicMock() + tasks = [ + _make_task(id="t-pending", status=TaskStatus.PENDING), + _make_task(id="t-running", status=TaskStatus.RUNNING), + _make_task(id="t-awaiting", status=TaskStatus.AWAITING_INPUT, input_request={"message": "input needed"}), + _make_task(id="t-success", status=TaskStatus.SUCCESS), + _make_task(id="t-failed", status=TaskStatus.FAILED), + ] + tm.get_all_tasks.return_value = tasks + service = _make_service(task_manager=tm) + summary = service.get_summary() + assert len(summary.by_type) == 1 # all migration + ts = summary.by_type[0] + assert ts.counts.pending == 1 + assert ts.counts.running == 1 + assert ts.counts.awaiting_input == 1 + assert ts.counts.success == 1 + assert ts.counts.failed == 1 + assert ts.total == 5 + assert summary.total_tasks == 5 + assert summary.active_tasks == 3 # pending + running + awaiting_input + + def test_multiple_types_grouped(self): + from src.core.task_manager.models import TaskStatus + tm = MagicMock() + tasks = [ + _make_task(id="t1", plugin_id="superset-backup", status=TaskStatus.SUCCESS), + _make_task(id="t2", plugin_id="superset-migration", status=TaskStatus.FAILED), + _make_task(id="t3", plugin_id="superset-backup", status=TaskStatus.PENDING), + ] + tm.get_all_tasks.return_value = tasks + service = _make_service(task_manager=tm) + summary = service.get_summary() + assert len(summary.by_type) == 2 + for ts in summary.by_type: + if "backup" in ts.task_type.value: + assert ts.total == 2 # SUCCESS + PENDING + assert ts.counts.success == 1 + assert ts.counts.pending == 1 + elif "migration" in ts.task_type.value: + assert ts.total == 1 + assert ts.counts.failed == 1 + + def test_pending_not_zero(self): + """Verify PENDING/AWAITING_INPUT populate separate buckets, not collapsed into running.""" + from src.core.task_manager.models import TaskStatus + tm = MagicMock() + tasks = [ + _make_task(id="t-pending", status=TaskStatus.PENDING), + _make_task(id="t-running", status=TaskStatus.RUNNING), + _make_task(id="t-awaiting", status=TaskStatus.AWAITING_INPUT, input_request={"message": "input needed"}), + ] + tm.get_all_tasks.return_value = tasks + service = _make_service(task_manager=tm) + summary = service.get_summary() + ts = summary.by_type[0] + assert ts.counts.pending == 1 + assert ts.counts.awaiting_input == 1 + assert ts.counts.running == 1 # separate from pending/awaiting +# #endregion test_get_summary + + # #region test_get_report_detail [C:2] [TYPE Function] # @BRIEF Test get_report_detail. class TestGetReportDetail: diff --git a/frontend/src/lib/api/reports.ts b/frontend/src/lib/api/reports.ts index 1c68ca1f..e7b44226 100644 --- a/frontend/src/lib/api/reports.ts +++ b/frontend/src/lib/api/reports.ts @@ -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(options: ReportQueryOptions = {}): Promise { +export async function getReports(options: ReportQueryOptions = {}): Promise { try { const query = buildReportQueryString(options); - return await api.fetchApi(`/reports${query ? `?${query}` : ''}`); + return await api.fetchApi(`/reports${query ? `?${query}` : ''}`); } catch (error) { throw normalizeApiError(error); } @@ -91,15 +93,43 @@ export async function getReports(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(reportId: string): Promise { +export async function getReportDetail(reportId: string): Promise { try { - return await api.fetchApi(`/reports/${reportId}`); + return await api.fetchApi(`/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 { + try { + return await api.fetchApi('/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 { + return await api.fetchApi('/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 { + return await api.requestApi('/settings/reports', 'PUT', settings); +} +// #endregion updateReportsSettings // #endregion ReportsApi diff --git a/frontend/src/lib/components/layout/TaskDrawer.svelte b/frontend/src/lib/components/layout/TaskDrawer.svelte index bc993eca..255d5cae 100644 --- a/frontend/src/lib/components/layout/TaskDrawer.svelte +++ b/frontend/src/lib/components/layout/TaskDrawer.svelte @@ -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} - + + {#if taskStatus !== "SUCCESS" && taskStatus !== "FAILED"}
@@ -889,6 +907,7 @@ {$t.tasks?.footer_text}

+ {/if} {/if} diff --git a/frontend/src/lib/components/reports/ReportCard.svelte b/frontend/src/lib/components/reports/ReportCard.svelte index 1ed85c9a..7ca7f196 100644 --- a/frontend/src/lib/components/reports/ReportCard.svelte +++ b/frontend/src/lib/components/reports/ReportCard.svelte @@ -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 @@ diff --git a/frontend/src/lib/components/reports/SummaryPanel.svelte b/frontend/src/lib/components/reports/SummaryPanel.svelte new file mode 100644 index 00000000..e75c346c --- /dev/null +++ b/frontend/src/lib/components/reports/SummaryPanel.svelte @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + +{#if screenState === 'loading'} +
+ {#each [1, 2, 3, 4] as n (n)} +
+ {/each} +
+{:else if summary && summary.by_type.length > 0} +
+ {#each summary.by_type as typeSummary (typeSummary.task_type)} + +
+ {typeSummary.display_label} + ({typeSummary.total}) +
+
+ {#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)} + + {/each} +
+ {/each} + {#if screenState === 'disconnected'} +
+ + Данные могут быть неактуальны + +
+ {/if} +
+{:else} + +
Нет активных задач
+{/if} + diff --git a/frontend/src/lib/components/reports/__tests__/SummaryPanel.ux.test.ts b/frontend/src/lib/components/reports/__tests__/SummaryPanel.ux.test.ts new file mode 100644 index 00000000..147ea098 --- /dev/null +++ b/frontend/src/lib/components/reports/__tests__/SummaryPanel.ux.test.ts @@ -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 diff --git a/frontend/src/lib/components/reports/reportTypeProfiles.ts b/frontend/src/lib/components/reports/reportTypeProfiles.ts index 0ac4f867..6fd5858d 100644 --- a/frontend/src/lib/components/reports/reportTypeProfiles.ts +++ b/frontend/src/lib/components/reports/reportTypeProfiles.ts @@ -40,6 +40,13 @@ export const REPORT_TYPE_PROFILES: Record = { 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'), diff --git a/frontend/src/lib/models/TaskCenterModel.svelte.ts b/frontend/src/lib/models/TaskCenterModel.svelte.ts new file mode 100644 index 00000000..017eb04a --- /dev/null +++ b/frontend/src/lib/models/TaskCenterModel.svelte.ts @@ -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 | null = null; + private _reconnectAttempt: number = 0; + private _reconnectTimer: ReturnType | null = null; + private _urlSyncTimeout: ReturnType | 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): Promise { + 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('/reports/summary'), + api.fetchApi('/settings/reports').catch(() => null), // non-critical + api.fetchApi(`/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 { + log('TaskCenter.Model', 'REASON', 'Loading reports', { page: this.page }); + this.screenState = 'loading'; + try { + const listData = await api.fetchApi(`/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 { + 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): 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 { + try { + const data = await api.fetchApi('/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 diff --git a/frontend/src/lib/models/__tests__/TaskCenterModel.test.ts b/frontend/src/lib/models/__tests__/TaskCenterModel.test.ts new file mode 100644 index 00000000..ba8e8c28 --- /dev/null +++ b/frontend/src/lib/models/__tests__/TaskCenterModel.test.ts @@ -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 diff --git a/frontend/src/lib/stores/taskDrawer.svelte.ts b/frontend/src/lib/stores/taskDrawer.svelte.ts index 93d6552f..88377e38 100644 --- a/frontend/src/lib/stores/taskDrawer.svelte.ts +++ b/frontend/src/lib/stores/taskDrawer.svelte.ts @@ -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; } diff --git a/frontend/src/lib/ui/Pagination.svelte b/frontend/src/lib/ui/Pagination.svelte index 22ae67a5..f268b8e2 100644 --- a/frontend/src/lib/ui/Pagination.svelte +++ b/frontend/src/lib/ui/Pagination.svelte @@ -14,6 +14,7 @@ page = $bindable(0), pageSize = 20, total = 0, + showingText = '', pageSizeOptions = [] as number[], onPageSizeChange, class: className = "", @@ -49,7 +50,7 @@
{#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)} {/each} @@ -78,7 +79,7 @@ {#if totalPages > 1}
- {#each pageNumbers as p} + {#each pageNumbers as p, i (String(p) + "-" + i)} {#if p === "..."} {:else} diff --git a/frontend/src/lib/utils/dateFormat.ts b/frontend/src/lib/utils/dateFormat.ts index 2db7f9f5..aac72e93 100644 --- a/frontend/src/lib/utils/dateFormat.ts +++ b/frontend/src/lib/utils/dateFormat.ts @@ -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 diff --git a/frontend/src/routes/reports/+page.svelte b/frontend/src/routes/reports/+page.svelte index ff1a47e3..b8287273 100644 --- a/frontend/src/routes/reports/+page.svelte +++ b/frontend/src/routes/reports/+page.svelte @@ -1,207 +1,219 @@ - - - + + - - - - - - - - - - - - + + + + + + + + + + + + + + -
- null} - actions={() => null} - /> - -
-
- - - - - - - +
+ +
+ {#if m.wsConnected} + + {:else} + + {/if} + {#if m.lastUpdated} + + Обновлено: {Math.round((Date.now() - m.lastUpdated.getTime()) / 60000)} мин назад + + {/if} +
-
+ - {#if loading} -
- {$t.reports?.loading} -
- {:else if error} -
-

{error}

- -
- {:else if !collection || collection.total === 0} -
- {$t.reports?.empty} -
- {:else if collection.items.length === 0 && hasActiveFilters()} -
-

{$t.reports?.filtered_empty}

- -
- {:else} -
-
- -
- + {#if m.screenState === 'error'} +
+ Не удалось загрузить данные задач. Сервер временно недоступен. +
{/if} -
- + {#if m.screenState === 'disconnected'} +
+ Соединение потеряно. Попытка переподключения... + +
+ {/if} + + + 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'} +
+ + + +
+ {/if} + + +
+ +
+ + +
+
+ + +
+ {m.filteredTasks.length} задач(-и) + {#if m.hasActiveFilters} + + {/if} +
+ + +
+ {#if m.screenState === 'loading'} +
+ {#each [1, 2, 3, 4, 5] as n (n)} +
+ {/each} +
+ {:else if m.isEmpty} + + {:else if m.isFilteredEmpty} + m.clearFilters()} + /> + {:else} + m.selectTask(e.report.task_id || e.report.report_id)} + /> + {#if m.totalPages > 1} +
+ +
+ {/if} + {/if} +
+
+ diff --git a/frontend/src/routes/reports/+page.ts b/frontend/src/routes/reports/+page.ts new file mode 100644 index 00000000..4af045c9 --- /dev/null +++ b/frontend/src/routes/reports/+page.ts @@ -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; +} + +export function load({ url }: { url: URL }): PageData { + const initialFilters: Partial = {}; + + 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 diff --git a/frontend/src/types/reports.ts b/frontend/src/types/reports.ts new file mode 100644 index 00000000..c0f1f0a8 --- /dev/null +++ b/frontend/src/types/reports.ts @@ -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 | null; + validation_record?: Record | null; + error_context: ErrorContext | null; + source_ref?: Record | 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[]; + diagnostics: Record | 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 | null; + result: Record | 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 diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index 385722bc..858916d9 100755 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -8,7 +8,8 @@ const config = { kit: { alias: { '$components': 'src/components', // @DEPRECATED Legacy alias — use $lib for new code - '$lib': 'src/lib' + '$lib': 'src/lib', + '$types': 'src/types' }, adapter: adapter({ pages: 'build', diff --git a/run.sh b/run.sh index 0b1c970b..a4d83e91 100755 --- a/run.sh +++ b/run.sh @@ -190,6 +190,48 @@ sys.exit(1) ensure_encryption_key +# JWT secret preflight (generates if missing/invalid) +ensure_jwt_secret() { + local ENV_FILE="backend/.env" + local key="" + + if [ ! -f "$ENV_FILE" ]; then + mkdir -p "$(dirname "$ENV_FILE")" + fi + + # Extract existing key from .env + if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC2013 + for line in $(grep "^JWT_SECRET=" "$ENV_FILE" | head -1); do + key="${line#JWT_SECRET=}" + done + fi + + # Validate existing key (must be non-empty and reasonably long) + if [ -n "$key" ] && [ ${#key} -ge 16 ]; then + echo "JWT secret preflight: existing JWT_SECRET reused from $ENV_FILE" + return + fi + + # Generate new key + local new_key + new_key=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") + + if [ -f "$ENV_FILE" ] && grep -q "^JWT_SECRET=" "$ENV_FILE"; then + # Replace invalid key + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$new_key/" "$ENV_FILE" + else + sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$new_key/" "$ENV_FILE" + fi + else + echo "JWT_SECRET=$new_key" >> "$ENV_FILE" + fi + echo "JWT secret preflight: JWT_SECRET generated and saved to $ENV_FILE" +} + +ensure_jwt_secret + # Backend dependency management setup_backend() { if [ "$SKIP_INSTALL" = true ]; then diff --git a/specs/034-task-status-center/contracts/modules.md b/specs/034-task-status-center/contracts/modules.md new file mode 100644 index 00000000..7f37d782 --- /dev/null +++ b/specs/034-task-status-center/contracts/modules.md @@ -0,0 +1,474 @@ +#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] + +```python +# 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] + +```python +# 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] + +```python +# 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] + +```python +# 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] + +```typescript +// 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): Promise { ... } + connectWebSocket(): void { ... } + disconnectWebSocket(): void { ... } + destroy(): void { ... } + + // === ACTIONS === + applyFilter(partial: Partial): void { ... } + clearFilters(): void { ... } + selectTask(taskId: string): void { ... } + async refreshSummary(): Promise { ... } + 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] + +```svelte + + + + + + + + + + + + + + + + + + + +{#if screenState === 'loading'} + +{:else if summary} +
+ {#each summary.by_type as typeSummary} + {#each STATUS_KEYS as status} + onFilterByTypeAndStatus(typeSummary.task_type, status)}> + + + {/each} + {/each} +
+{/if} + +``` + +### 3.3 TaskCenterReportsPage [C:4] [TYPE Page] + +```svelte + + + + + + + + + + + + + + + + + + + + + + + + + + +{#if m.screenState === 'disconnected'} +
+ Соединение потеряно. Попытка переподключения... + +
+{/if} + + m.applyFilter({ task_types: [type], statuses: [status] })} +/> + + + + { m.page = p; }} +/> + + +``` + +### 3.4 ReportsApiClient [C:3] [TYPE Module] + +```typescript +// 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 ). + +// 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 { + const response = await api.fetchApi('/reports/summary'); + return response.json(); +} + +// EXISTING — enhance with types +export async function getReports(options: Partial): Promise { + const query = buildReportQueryString(options); + const response = await api.fetchApi(`/reports${query}`); + return response.json(); +} + +export async function getReportDetail(reportId: string): Promise { + const response = await api.fetchApi(`/reports/${reportId}`); + return response.json(); +} +// #endregion ReportsApiClient +``` + +### 3.5 FilterBar [C:3] [TYPE Component] + +```svelte + + + + + + + + + + + + + + +``` + +### 3.6 TaskList [C:4] [TYPE Component] + +```svelte + + + + + + + + + + + + + + + + +``` + +### 3.7 ReportsTypeProfiles [C:2] [TYPE Module] + +```typescript +// 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 = { + 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 diff --git a/specs/034-task-status-center/contracts/ux/alternatives.md b/specs/034-task-status-center/contracts/ux/alternatives.md new file mode 100644 index 00000000..fb5c927b --- /dev/null +++ b/specs/034-task-status-center/contracts/ux/alternatives.md @@ -0,0 +1,89 @@ +#region UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,task-status-center] +@defgroup Ux Design alternatives explored for Task Status Center. + +## Screen: Центр статусов задач (/reports) + +### Navigation +- ✅ CHOSEN: Переработка существующего пункта «Отчёты» (/reports) — не ломает привычки, не увеличивает меню +- ❌ Rejected: Отдельный пункт «Центр статусов» — загромождает боковое меню (15+ пунктов) +- ❌ Rejected: Dashboard на главной (/dashboards) — смешивает задачи с дашбордами + +### Layout +- ✅ CHOSEN: Двухколоночный (сводка 1/3 слева, список 2/3 справа) — контекст не теряется при скролле +- ❌ Rejected: Одноколоночный вертикальный поток — 30 карточек сводки = много скролла до списка +- ❌ Rejected: Сворачиваемая панель сводки — дополнительное состояние интерфейса + +### Data Density (Summary) +- ✅ CHOSEN: Только типы, присутствующие в системе + настройка видимости групп — не перегружает +- ❌ Rejected: Полная сетка 6×5 (30 карточек) — перегружено, много нулей +- ❌ Rejected: Сворачиваемые группы по типам — теряется «обзор одним взглядом» + +### Group Visibility Configuration +- ✅ CHOSEN: Двухуровневая: оператор (localStorage) + админ (backend, приоритет) — гибкость + контроль +- ❌ Rejected: Только локальные настройки — нет глобального контроля +- ❌ Rejected: Только админ-настройки — оператор не может быстро адаптировать под себя + +### Loading State +- ✅ CHOSEN: Скелетоны в обеих колонках — структура layout видна сразу, снижает воспринимаемую задержку +- ❌ Rejected: Полноэкранный spinner — блокирует весь view, ощущается медленнее +- ❌ Rejected: Кешированные данные + индикатор — нет кеша при первом визите + +### Empty State +- ✅ CHOSEN: Сводка с нулями + EmptyState с CTA в списке — сигнал «система работает, просто пусто» +- ❌ Rejected: Пустые колонки с текстом — не направляет к действию +- ❌ Rejected: Скрыть сводку — теряется двухколоночный layout + +### Disconnected State +- ✅ CHOSEN: Жёлтый индикатор сразу + баннер через 2с + данные видимы — предотвращает мерцание при блипах +- ❌ Rejected: Только красный индикатор — слишком незаметно +- ❌ Rejected: Полупрозрачный overlay — мешает чтению данных + +### Error State +- ✅ CHOSEN: Баннер в правой колонке + авто-retry 10с — не блокирует весь экран +- ❌ Rejected: Полноэкранная ошибка — слишком агрессивно для transient 503 +- ❌ Rejected: Пустые колонки + toast — слишком незаметно для критичной ошибки + +### Filtered Empty State +- ✅ CHOSEN: Контекстный EmptyState + «Сбросить фильтры» + сводка видна — оператор может сменить фильтр +- ❌ Rejected: Просто «Нет результатов» — не контекстно + +### Stale Data State +- ✅ CHOSEN: Timestamp «Обновлено: X мин назад» в PageHeader — данные полностью читаемы +- ❌ Rejected: Полупрозрачный overlay — мешает чтению +- ❌ Rejected: Без индикатора — оператор может действовать на устаревших данных + +### Summary Card Click +- ✅ CHOSEN: Заменяет тип+статус, сохраняет время+поиск — карточка = тип×статус, остальное независимо +- ❌ Rejected: Заменяет все фильтры — теряет временной диапазон +- ❌ Rejected: Добавляет к текущим — непредсказуемо + +### Text Search +- ✅ CHOSEN: Debounce 300мс — баланс отзывчивость/производительность +- ❌ Rejected: Мгновенно на каждый символ — нагрузка на DOM при 200+ задачах +- ❌ Rejected: По Enter — нет мгновенной обратной связи + +### Task Row Click +- ✅ CHOSEN: Мгновенно открывает Task Drawer — US3: «кликнуть → открывается» +- ❌ Rejected: Сначала выделение, потом двойной клик — лишний клик +- ❌ Rejected: Два действия (клик = drawer, иконка = превью) — оператор должен знать + +### Sort Controls +- ✅ CHOSEN: Select в FilterBar — список это карточки, не таблица с заголовками +- ❌ Rejected: Заголовки колонок таблицы — нет таблицы с колонками +- ❌ Rejected: Гибрид (заголовки + FilterBar) — лишняя работа + +### Pagination +- ✅ CHOSEN: Классическая (Назад/Вперёд + «Стр. X из Y») — активные всегда на 1-й, URL-синхронизация +- ❌ Rejected: Infinite scroll — ломает «активные сверху», нельзя поделиться ссылкой +- ❌ Rejected: «Загрузить ещё» — лишний клик + +### Connection Indicator Placement +- ✅ CHOSEN: В PageHeader рядом с «Обновить» — не мешает при скролле +- ❌ Rejected: Плавающий в углу — мешает на мобильных +- ❌ Rejected: В FilterBar — не относится к фильтрации + +### Disabled Group Display (admin-disabled) +- ✅ CHOSEN: Просто скрывать — оператор не видит, чище интерфейс +- ❌ Rejected: Показывать заблокированными с замком — лишний визуальный шум + +#endregion UxAlternatives diff --git a/specs/034-task-status-center/contracts/ux/api-ux.md b/specs/034-task-status-center/contracts/ux/api-ux.md new file mode 100644 index 00000000..d69da1e5 --- /dev/null +++ b/specs/034-task-status-center/contracts/ux/api-ux.md @@ -0,0 +1,122 @@ +#region UxApiShapes [C:3] [TYPE ADR] [SEMANTICS ux,api,shapes,task-status-center] +@defgroup Ux API interaction shapes for Task Status Center. + +## GET /api/reports/summary + +**Request:** +``` +GET /api/reports/summary +Authorization: Bearer +``` + +**Response variants:** + +| Status | Body | UI State | +|--------|------|----------| +| 200 | `{ by_type: [TaskTypeSummary], total_tasks: int, active_tasks: int }` | `ready` or `empty` (if total=0) | +| 401 | `{ detail: "Not authenticated" }` | Redirect to /login | +| 403 | `{ detail: "Permission denied for tasks:READ" }` | Toast "Недостаточно прав" | +| 503 | `{ detail: "Service unavailable" }` | `error` + auto-retry 10s | + +**Loading UX:** Skeleton in left column (3-4 pulse cards). No debounce — single request. + +--- + +## GET /api/reports (enhanced) + +**Request:** +``` +GET /api/reports?page=1&page_size=20&task_types=migration,backup&statuses=running,failed&search=dashboard&time_from=...&time_to=...&sort_by=updated_at&sort_order=desc +Authorization: Bearer +``` + +**Response variants:** + +| Status | Body | UI State | +|--------|------|----------| +| 200 (results) | `{ items: [TaskReport], total, page, page_size, has_next, applied_filters }` | `ready` | +| 200 (empty) | `{ items: [], total: 0, page: 1, page_size: 20, has_next: false, applied_filters }` | `filtered_empty` | +| 401 | `{ detail: "Not authenticated" }` | Redirect to /login | +| 403 | `{ detail: "Permission denied" }` | Toast "Недостаточно прав" | +| 422 | `{ detail: [{ msg: "sort_by must be one of: ..." }] }` | Toast "Неверный параметр" + reset | + +**Loading UX:** Skeleton rows in right column. On filter change: 200ms threshold (if response faster — instant swap, no skeleton). + +--- + +## GET /api/settings/reports (NEW) + +**Request:** +``` +GET /api/settings/reports +Authorization: Bearer +``` + +**Response variants:** + +| Status | Body | UI State | +|--------|------|----------| +| 200 | `{ disabled_task_types: ["clean_release"] }` | Merge with localStorage visibleTypes | +| 200 (default) | `{ disabled_task_types: [] }` | All types visible | +| 401 | `{ detail: "Not authenticated" }` | Redirect to /login | +| 403 | `{ detail: "Permission denied" }` | Fallback: all types visible | + +--- + +## PUT /api/settings/reports (NEW, admin only) + +**Request:** +``` +PUT /api/settings/reports +Authorization: Bearer +Content-Type: application/json +{ "disabled_task_types": ["clean_release", "unknown"] } +``` + +**Response variants:** + +| Status | Body | UI State | +|--------|------|----------| +| 200 | `{ disabled_task_types: ["clean_release", "unknown"] }` | Toast "Настройки сохранены" | +| 401 | `{ detail: "Not authenticated" }` | Redirect to /login | +| 403 | `{ detail: "Permission denied for settings:WRITE" }` | Toast "Недостаточно прав" | +| 422 | `{ detail: [{ msg: "invalid task type" }] }` | Inline error on field | + +**Loading UX:** Spinner on "Сохранить" button. Save button hidden for non-admins. + +--- + +## WebSocket /ws/task-events (existing, reused) + +**Connection:** +``` +ws://host/ws/task-events?token= +``` + +**Message format (existing):** +```json +{ "type": "task_status", "task_id": "abc-123", "task": { "id", "plugin_id", "status", "started_at", "finished_at", "user_id", "params", "result" } } +``` + +**UI reactions:** + +| Event | List | Summary | +|-------|------|---------| +| New task (PENDING) | slide-in top | +1 pending | +| PENDING→RUNNING | badge gray→blue | -1 pending, +1 running | +| RUNNING→SUCCESS | badge→green, row flash green | -1 running, +1 success | +| RUNNING→FAILED | badge→red, row flash red | -1 running, +1 failed | +| →AWAITING_INPUT | badge→yellow, "Ответить" button | +1 awaiting_input | +| Task removed (retention) | row disappears | -1 from last status | + +**Debounce:** 100ms buffer → batch apply → $derived recompute. + +**Reconnect:** 1s→2s→4s→8s→max 30s. On reconnect: GET /api/reports/summary to sync missed events. + +--- + +## WebSocket /ws/logs/{task_id} (existing, for Task Drawer) + +Unchanged. Task Drawer connects on open, streams live logs. Closes on drawer close or task completion. + +#endregion UxApiShapes diff --git a/specs/034-task-status-center/contracts/ux/decisions.md b/specs/034-task-status-center/contracts/ux/decisions.md new file mode 100644 index 00000000..cfbb34e9 --- /dev/null +++ b/specs/034-task-status-center/contracts/ux/decisions.md @@ -0,0 +1,25 @@ +#region UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,task-status-center] +@defgroup Ux Final UX design decisions for Task Status Center. + +## Screen: Центр статусов задач (/reports) + +- **Navigation**: Переработка существующего /reports (пункт «Отчёты» в боковом меню) +- **Layout**: Двухколоночный — сводка 1/3 слева, список 2/3 справа. На <768px — одна колонка (сводка сворачивается) +- **Summary density**: Только типы, присутствующие в системе. Настройка видимости: ⚙ на сводке → чекбоксы (localStorage) +- **Group visibility**: Двухуровневая — оператор (localStorage, быстро) + админ (backend, приоритет). Отключённые админом — просто скрыты +- **Loading**: Скелетоны в обеих колонках (pulse-анимация) +- **Empty**: Сводка с нулями + EmptyState с CTA («Запустить перевод/миграцию/бекап») в правой колонке +- **Disconnected**: Жёлтый индикатор сразу → баннер через 2с → данные видимы → авто-reconnect (1с,2с,4с,8с,макс 30с) +- **Error**: Баннер в правой колонке + «Повторить» + авто-retry 10с +- **Filtered empty**: Контекстный EmptyState («Нет выполняющихся бекапов») + «Сбросить фильтры» + сводка видна +- **Stale**: Timestamp «Обновлено: X мин назад» в PageHeader, без overlay +- **Summary card click**: Заменяет тип+статус, сохраняет время+поиск +- **Text search**: Debounce 300мс, URL-синхронизация 500мс +- **Task row click**: Мгновенно открывает Task Drawer +- **Sort**: Select (sort_by + sort_order) в FilterBar. Активные всегда сверху (инвариант модели) +- **Pagination**: Классическая, 20/страница, URL-синхронизация ?page=N +- **Connection indicator**: В PageHeader (●/◉/○ + aria-label) +- **Mobile**: <768px — одна колонка, сводка сворачивается в полосу итогов +- **Accessibility**: aria-label на бейджах, role="alert" на баннерах, Tab-навигация, Esc=закрыть drawer, фокус-менеджмент + +#endregion UxDecisions diff --git a/specs/034-task-status-center/contracts/ux/design-tokens.md b/specs/034-task-status-center/contracts/ux/design-tokens.md new file mode 100644 index 00000000..28e9b20f --- /dev/null +++ b/specs/034-task-status-center/contracts/ux/design-tokens.md @@ -0,0 +1,98 @@ +#region UxDesignTokens [C:2] [TYPE ADR] [SEMANTICS ux,design-tokens,tailwind,task-status-center] +@defgroup Ux Design token application for Task Status Center. + +## Applied Tokens + +### Layout + +| Element | Token | Usage | +|---------|-------|-------| +| Page background | `bg-surface-page` | Root container | +| Summary panel | `bg-surface-card border border-border` | Left column card | +| Task list | `bg-surface-card border border-border` | Right column card | +| Filter bar | `bg-surface-muted` | Filter container | +| Page header | (via `` atom) | Title + actions | + +### Status Colors (badges, indicators) + +| Status | Background | Text | Border | +|--------|-----------|------|--------| +| running | `bg-primary/10` | `text-primary` | — | +| pending | `bg-surface-muted` | `text-text-muted` | — | +| success | `bg-success-light` | `text-success` | — | +| failed | `bg-destructive-light` | `text-destructive` | `border-l-4 border-destructive-ring` (row) | +| awaiting_input | `bg-warning-light` | `text-warning` | — | + +### Connection Indicator + +| State | Color | Icon | +|-------|-------|------| +| connected | `bg-success` | `●` (filled circle) | +| reconnecting | `bg-warning` | `◉` (pulsing circle, `animate-pulse`) | +| disconnected | `bg-destructive` | `○` (empty circle) | + +Indicator: `w-3 h-3 rounded-full` span in PageHeader. + +### Banners + +| Banner | Container | Text | +|--------|-----------|------| +| Disconnected | `bg-warning-light border border-warning-ring text-warning p-3 rounded-lg` | "Соединение потеряно..." | +| Error | `bg-destructive-light border border-destructive-ring text-destructive p-3 rounded-lg` | "Не удалось загрузить..." | + +### Skeleton + +| Element | Classes | +|---------|---------| +| Summary card skeleton | `bg-surface-muted rounded-lg h-16 animate-pulse` | +| Task row skeleton | `bg-surface-muted rounded h-12 animate-pulse` | + +### Task Row + +| Element | Classes | +|---------|---------| +| Row container | `bg-surface-card border border-border rounded-lg p-3 hover:border-border-strong cursor-pointer transition-colors` | +| FAILED row | `border-l-4 border-destructive-ring` | +| Type icon | `w-5 h-5 text-text-muted` (via `` atom) | +| Status badge | `rounded-full px-2.5 py-0.5 text-xs font-medium` + status color | +| Relative time | `text-text-muted text-sm` | +| Duration | `text-text-muted text-sm` | + +### Summary Card (clickable) + +| Element | Classes | +|---------|---------| +| Card | `bg-surface-card border border-border rounded-lg p-3 hover:border-border-strong cursor-pointer transition-all` | +| Type label | `text-text font-medium text-sm` | +| Count badge | `rounded-full px-2.5 py-0.5 text-xs font-medium` + status color | +| Count = 0 | `opacity-50` (dimmed but visible) | + +### Filter Bar + +| Element | Classes | +|---------|---------| +| Container | `bg-surface-muted rounded-lg p-3 flex flex-wrap gap-3 items-center` | +| Active filter badge | `bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs` | +| Clear button | (via `