feat(reports): Task Status Center — unified /reports dashboard
Страница /reports трансформирована в Центр статусов задач:
Backend:
- GET /api/reports/summary — агрегированные счётчики тип×статус (5 корзин)
- GET/PUT /api/settings/reports — глобальные настройки отчётов
- _filter_tasks_by_rbac() — row-level фильтрация по роли
- normalize_task_report: LLM-валидация с ошибками → FAILED/PARTIAL
- get_summary(): 5 корзин pending/running/awaiting_input/success/failed
Frontend:
- TaskCenterModel.svelte.ts (400 строк) — Screen Model
- SummaryPanel — сводная панель с цветовым кодированием и active filter
- ReportCard — humanized labels, duration, task_id, failed border
- FilterBar — search + sort + time range с label'ами
- Pagination — showingText, уникальные id для select
- Quick views: «Упавшие», «В работе», «Успешные»
- TaskDrawer: scroll-to-error, footer скрыт для terminal, «Н/Д» fix
Тесты: 48 backend + 38 frontend (2521 всего)
Build: ✅ Console errors: 0
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// #region ReportsApi [C:3] [TYPE Module] [SEMANTICS report, api, search, filter, query]
|
||||
// @BRIEF Reports API client — list/detail retrieval, query-string building, error normalization.
|
||||
// @BRIEF Reports API client — list/detail retrieval, query-string building, error normalization, typed summary endpoint.
|
||||
// @LAYER API
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION DEPENDS_ON -> [ReportsTypes]
|
||||
// @PRE Shared API wrapper is configured for the current frontend auth/session context.
|
||||
// @POST Report list and detail helpers return backend payloads or normalized UI-safe errors.
|
||||
|
||||
import { api } from '../api';
|
||||
import type { ReportCollection, ReportDetailView, TaskSummary, ReportsSettings } from '$types/reports';
|
||||
|
||||
// #region reportApiTypes [C:1] [TYPE Block] [SEMANTICS types, reports]
|
||||
interface ReportQueryOptions {
|
||||
@@ -75,13 +77,13 @@ export function normalizeApiError(error: unknown): NormalizedError {
|
||||
// #region getReports [C:3] [TYPE Function] [SEMANTICS report, list, fetch]
|
||||
// @BRIEF Fetch unified report list using the shared API wrapper.
|
||||
// @PRE Valid auth context for protected endpoint.
|
||||
// @POST Returns parsed payload or throws normalized error.
|
||||
// @POST Returns parsed ReportCollection or throws normalized error.
|
||||
// @SIDE_EFFECT Performs authenticated GET request through api.fetchApi.
|
||||
// @RELATION DEPENDS_ON -> [fetchApi]
|
||||
export async function getReports<T = unknown>(options: ReportQueryOptions = {}): Promise<T> {
|
||||
export async function getReports(options: ReportQueryOptions = {}): Promise<ReportCollection> {
|
||||
try {
|
||||
const query = buildReportQueryString(options);
|
||||
return await api.fetchApi<T>(`/reports${query ? `?${query}` : ''}`);
|
||||
return await api.fetchApi<ReportCollection>(`/reports${query ? `?${query}` : ''}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
@@ -91,15 +93,43 @@ export async function getReports<T = unknown>(options: ReportQueryOptions = {}):
|
||||
// #region getReportDetail [C:3] [TYPE Function] [SEMANTICS report, detail, fetch]
|
||||
// @BRIEF Fetch one report detail by report_id.
|
||||
// @PRE reportId is non-empty string; valid auth context.
|
||||
// @POST Returns parsed detail payload or throws normalized error.
|
||||
// @POST Returns parsed ReportDetailView or throws normalized error.
|
||||
// @SIDE_EFFECT Performs authenticated GET request through api.fetchApi.
|
||||
// @RELATION DEPENDS_ON -> [fetchApi]
|
||||
export async function getReportDetail<T = unknown>(reportId: string): Promise<T> {
|
||||
export async function getReportDetail(reportId: string): Promise<ReportDetailView> {
|
||||
try {
|
||||
return await api.fetchApi<T>(`/reports/${reportId}`);
|
||||
return await api.fetchApi<ReportDetailView>(`/reports/${reportId}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// #endregion getReportDetail
|
||||
|
||||
// #region getReportsSummary [C:2] [TYPE Function] [SEMANTICS report, summary, fetch]
|
||||
// @BRIEF Fetch aggregated task summary for the summary dashboard.
|
||||
// @PRE Valid auth context.
|
||||
// @POST Returns parsed TaskSummary or throws normalized error.
|
||||
// @SIDE_EFFECT Performs authenticated GET request through api.fetchApi.
|
||||
export async function getReportsSummary(): Promise<TaskSummary> {
|
||||
try {
|
||||
return await api.fetchApi<TaskSummary>('/reports/summary');
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// #endregion getReportsSummary
|
||||
|
||||
// #region getReportsSettings [C:2] [TYPE Function] [SEMANTICS report, settings, fetch]
|
||||
// @BRIEF Fetch reports settings (admin-disabled task types).
|
||||
export async function getReportsSettings(): Promise<ReportsSettings> {
|
||||
return await api.fetchApi<ReportsSettings>('/settings/reports');
|
||||
}
|
||||
// #endregion getReportsSettings
|
||||
|
||||
// #region updateReportsSettings [C:2] [TYPE Function] [SEMANTICS report, settings, update]
|
||||
// @BRIEF Update reports settings (admin-only: disabled task types).
|
||||
export async function updateReportsSettings(settings: ReportsSettings): Promise<ReportsSettings> {
|
||||
return await api.requestApi<ReportsSettings>('/settings/reports', 'PUT', settings);
|
||||
}
|
||||
// #endregion updateReportsSettings
|
||||
// #endregion ReportsApi
|
||||
|
||||
@@ -82,6 +82,23 @@
|
||||
|
||||
let isOpen = $derived(Boolean(taskDrawerStore.value?.isOpen));
|
||||
let activeTaskId = $derived(taskDrawerStore.value?.activeTaskId || null);
|
||||
let scrollToHint = $derived(taskDrawerStore.value?.scrollToHint || null);
|
||||
|
||||
// Scroll to last error when opening a FAILED task
|
||||
$effect(() => {
|
||||
if (scrollToHint === 'last_error' && activeTaskId) {
|
||||
const timeout = setTimeout(() => {
|
||||
const drawer = document.querySelector('[role="dialog"]');
|
||||
if (drawer) {
|
||||
const logs = drawer.querySelectorAll('.text-destructive');
|
||||
const lastError = logs[logs.length - 1];
|
||||
if (lastError) lastError.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
else drawer.scrollTop = drawer.scrollHeight;
|
||||
}
|
||||
}, 600);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
let isAssistantOpen = $derived(Boolean(assistantChatStore.value?.isOpen));
|
||||
let assistantOffset = $derived(
|
||||
isAssistantOpen ? "min(100vw, 28rem)" : "0px",
|
||||
@@ -718,7 +735,7 @@
|
||||
disabled={!derivedTaskSummary?.primaryDashboardId ||
|
||||
!derivedTaskSummary?.targetEnvId}
|
||||
>
|
||||
{#if derivedTaskSummary?.targetEnvName}
|
||||
{#if derivedTaskSummary?.targetEnvName && derivedTaskSummary.targetEnvName !== '\u041D/\u0414'}
|
||||
{(
|
||||
$t.tasks?.open_dashboard_target || "Open dashboard in {env}"
|
||||
).replace("{env}", derivedTaskSummary.targetEnvName)}
|
||||
@@ -880,7 +897,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<!-- Footer — hidden for terminal states -->
|
||||
{#if taskStatus !== "SUCCESS" && taskStatus !== "FAILED"}
|
||||
<div
|
||||
class="flex items-center gap-2 justify-center px-4 py-3 border-t border-border bg-surface-page"
|
||||
>
|
||||
@@ -889,6 +907,7 @@
|
||||
{$t.tasks?.footer_text}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion TaskDrawer -->
|
||||
{/if}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { getReportTypeProfile } from "./reportTypeProfiles.js";
|
||||
import { formatDateTime } from "$lib/utils/dateFormat";
|
||||
import { formatDateTime, formatDuration } from "$lib/utils/dateFormat.js";
|
||||
|
||||
let { report, selected = false, onselect = () => {} } = $props();
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="w-full rounded-xl border p-4 text-left shadow-sm transition hover:border-border-strong hover:bg-surface-page hover:shadow {selected
|
||||
class="w-full rounded-xl border p-4 text-left transition hover:border-border-strong hover:bg-surface-page hover:shadow {selected
|
||||
? 'border-primary-ring bg-primary-light'
|
||||
: 'border-border bg-surface-card'}"
|
||||
: 'border-border bg-surface-card'} {report?.status === 'failed' ? 'border-l-4 border-l-destructive-ring' : ''}"
|
||||
onclick={onSelect}
|
||||
aria-label={`${$t.reports?.title} ${report?.report_id || ""} ${$t.reports?.type} ${profileLabel || $t.reports?.unknown_type}`}
|
||||
>
|
||||
@@ -92,7 +92,21 @@
|
||||
<p class="text-sm font-medium text-text">
|
||||
{report?.summary || $t.reports?.not_provided}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-text-muted">{formatDateTime(report?.updated_at)}</p>
|
||||
{#if report?.status === 'in_progress' && report?.error_context?.code === 'AWAITING_INPUT'}
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<span class="bg-warning-light text-warning rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
Ожидает ввода
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1.5 flex items-center justify-between gap-2 text-[11px] text-text-muted">
|
||||
<span class="font-mono truncate">{report?.task_id?.slice(0, 8) || report?.report_id?.slice(0, 8) || 'N/A'}</span>
|
||||
{#if report?.started_at && report?.updated_at}
|
||||
<span>{formatDuration(report.started_at, report.updated_at)}</span>
|
||||
{:else if report?.updated_at}
|
||||
<span>{formatDateTime(report.updated_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- #endregion ReportCard -->
|
||||
|
||||
95
frontend/src/lib/components/reports/SummaryPanel.svelte
Normal file
95
frontend/src/lib/components/reports/SummaryPanel.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<!-- #region SummaryPanel [C:4] [TYPE Component] [SEMANTICS component, summary, dashboard, websocket-reactive, task-status-center] -->
|
||||
<!-- @BRIEF Summary dashboard panel — grid of clickable cards showing task counts by type × status. -->
|
||||
<!-- @LAYER UI Component -->
|
||||
<!-- @UX_STATE idle: cards rendered normally, clickable -->
|
||||
<!-- @UX_STATE loading: skeleton placeholders (4 cards with pulse animation) -->
|
||||
<!-- @UX_STATE empty: all cards show zero counts -->
|
||||
<!-- @UX_STATE reconnecting: last known counts visible, yellow pulsing indicator -->
|
||||
<!-- @UX_STATE disconnected: last known counts visible, opacity reduced, "disconnected" badge -->
|
||||
<!-- @UX_FEEDBACK Count change: number animates (CSS transition). Card click: emits filter event. -->
|
||||
<!-- @UX_RECOVERY Disconnected: auto-reconnect indicator. Click → manual reconnect button. -->
|
||||
<!-- @UX_REACTIVITY Props: summary: TaskSummary | null, screenState: ScreenState. Derived: by_type grid. -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
|
||||
<!-- @PRE summary is non-null when screenState is 'ready' or 'disconnected'. -->
|
||||
<!-- @POST Clicking a card calls onFilterByTypeAndStatus(taskType, status). -->
|
||||
<script lang="ts">
|
||||
import type { TaskType, ReportStatus, ScreenState, StatusCounts, TaskSummary } from '$types/reports';
|
||||
|
||||
const STATUS_CONFIG: { key: keyof StatusCounts; label: string; colorClass: string }[] = [
|
||||
{ key: 'running', label: 'Выполняется', colorClass: 'bg-primary/10 text-primary' },
|
||||
{ key: 'pending', label: 'Ожидает', colorClass: 'bg-surface-muted text-text-muted' },
|
||||
{ key: 'awaiting_input', label: 'Ожидает ввода', colorClass: 'bg-warning-light text-warning' },
|
||||
{ key: 'success', label: 'Успешно', colorClass: 'bg-success-light text-success' },
|
||||
{ key: 'failed', label: 'Упало', colorClass: 'bg-destructive-light text-destructive' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
summary: TaskSummary | null;
|
||||
screenState: ScreenState;
|
||||
onFilterByTypeAndStatus: (_type: TaskType, _status: ReportStatus) => void;
|
||||
onReconnect?: () => void;
|
||||
activeFilters?: { task_types: TaskType[]; statuses: ReportStatus[] };
|
||||
}
|
||||
|
||||
let { summary, screenState, onFilterByTypeAndStatus, onReconnect = () => {}, activeFilters = { task_types: [], statuses: [] } }: Props = $props();
|
||||
|
||||
function getCount(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): number {
|
||||
const c = typeSummary.counts;
|
||||
return c[statusKey] ?? 0;
|
||||
}
|
||||
|
||||
function handleCardClick(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): void {
|
||||
// Map summary status keys to ReportStatus for filtering
|
||||
const statusMap: Record<string, ReportStatus> = {
|
||||
running: 'in_progress',
|
||||
pending: 'in_progress',
|
||||
awaiting_input: 'in_progress',
|
||||
success: 'success',
|
||||
failed: 'failed',
|
||||
};
|
||||
onFilterByTypeAndStatus(typeSummary.task_type, statusMap[statusKey] ?? 'in_progress');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if screenState === 'loading'}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{#each [1, 2, 3, 4] as n (n)}
|
||||
<div class="bg-surface-muted rounded-lg h-20 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if summary && summary.by_type.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each summary.by_type as typeSummary (typeSummary.task_type)}
|
||||
<!-- Type header -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-text text-sm font-medium">{typeSummary.display_label}</span>
|
||||
<span class="text-text-muted text-xs">({typeSummary.total})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
{#each STATUS_CONFIG as status (status.key)}
|
||||
{@const count = getCount(typeSummary, status.key)}
|
||||
{@const isActive = activeFilters.task_types.includes(typeSummary.task_type) && activeFilters.statuses.includes(status.key as ReportStatus)}
|
||||
<button
|
||||
class="flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-all hover:border-border-strong {screenState === 'disconnected' ? 'opacity-60' : ''} {status.colorClass} {isActive ? 'ring-2 ring-primary ring-offset-1' : ''}"
|
||||
onclick={() => handleCardClick(typeSummary, status.key)}
|
||||
aria-label="{typeSummary.display_label}, {status.label}: {count}"
|
||||
>
|
||||
<span class="text-sm font-mono min-w-[1.5rem] text-center font-semibold">{count}</span>
|
||||
<span class="text-xs truncate">{status.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{#if screenState === 'disconnected'}
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted mt-2">
|
||||
<span class="w-2 h-2 rounded-full bg-destructive"></span>
|
||||
Данные могут быть неактуальны
|
||||
<button class="underline cursor-pointer" onclick={onReconnect}>Подключиться</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- empty state - just placeholder -->
|
||||
<div class="text-text-muted text-sm py-4">Нет активных задач</div>
|
||||
{/if}
|
||||
<!-- #endregion SummaryPanel -->
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
// #region SummaryPanelTest [C:3] [TYPE Module] [SEMANTICS test,summary,ux,component]
|
||||
// @BRIEF UX tests for SummaryPanel component — states, transitions, click events.
|
||||
// @RELATION BINDS_TO -> [SummaryPanel]
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import SummaryPanel from '../SummaryPanel.svelte';
|
||||
import type { TaskSummary, ScreenState } from '$types/reports';
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('$lib/i18n/index.svelte.js', () => ({
|
||||
t: { subscribe: (fn: any) => { fn({}); return () => {}; } },
|
||||
_: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
const makeTypeSummary = (overrides = {}) => ({
|
||||
task_type: 'llm_verification' as const,
|
||||
display_label: 'llm_verification',
|
||||
icon_token: '',
|
||||
counts: { pending: 2, running: 3, awaiting_input: 1, success: 15, failed: 5 },
|
||||
total: 26,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSummary = (overrides = {}): TaskSummary => ({
|
||||
by_type: [makeTypeSummary()],
|
||||
total_tasks: 26,
|
||||
active_tasks: 6,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SummaryPanel — UX states', () => {
|
||||
const onFilter = vi.fn();
|
||||
const onReconnect = vi.fn();
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
// @UX_STATE: loading → skeleton placeholders (4 cards with pulse animation)
|
||||
it('shows skeleton cards in loading state', () => {
|
||||
const { container } = render(SummaryPanel, {
|
||||
summary: null,
|
||||
screenState: 'loading' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
// @UX_STATE: ready → cards rendered with counts
|
||||
it('renders type header and status counts in ready state', () => {
|
||||
render(SummaryPanel, {
|
||||
summary: makeSummary(),
|
||||
screenState: 'ready' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
expect(screen.getByText('llm_verification')).toBeDefined();
|
||||
expect(screen.getByText('(26)')).toBeDefined();
|
||||
});
|
||||
|
||||
// @UX_FEEDBACK: card click → calls onFilterByTypeAndStatus
|
||||
it('clicking a status card calls filter callback', async () => {
|
||||
render(SummaryPanel, {
|
||||
summary: makeSummary(),
|
||||
screenState: 'ready' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
const successButton = screen.getByLabelText('llm_verification, Успешно: 15');
|
||||
await fireEvent.click(successButton);
|
||||
expect(onFilter).toHaveBeenCalledWith('llm_verification', 'success');
|
||||
});
|
||||
|
||||
// @UX_STATE: empty → all zeros shown
|
||||
it('shows zero counts when summary has empty by_type', () => {
|
||||
const empty = makeSummary({ by_type: [], total_tasks: 0, active_tasks: 0 });
|
||||
render(SummaryPanel, {
|
||||
summary: empty,
|
||||
screenState: 'ready' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
expect(screen.getByText('Нет активных задач')).toBeDefined();
|
||||
});
|
||||
|
||||
// @UX_STATE: disconnected → opacity reduced + reconnect link
|
||||
it('shows disconnected indicator in disconnected state', () => {
|
||||
render(SummaryPanel, {
|
||||
summary: makeSummary(),
|
||||
screenState: 'disconnected' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
onReconnect,
|
||||
});
|
||||
expect(screen.getByText('Данные могут быть неактуальны')).toBeDefined();
|
||||
const reconnectBtn = screen.getByText('Подключиться');
|
||||
expect(reconnectBtn).toBeDefined();
|
||||
});
|
||||
|
||||
// @UX_RECOVERY: reconnect click → calls onReconnect
|
||||
it('clicking reconnect button calls onReconnect', async () => {
|
||||
render(SummaryPanel, {
|
||||
summary: makeSummary(),
|
||||
screenState: 'disconnected' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
onReconnect,
|
||||
});
|
||||
await fireEvent.click(screen.getByText('Подключиться'));
|
||||
expect(onReconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// @UX_STATE: multiple types rendered correctly
|
||||
it('renders multiple task types with their counts', () => {
|
||||
const multi = makeSummary({
|
||||
by_type: [
|
||||
makeTypeSummary({ task_type: 'backup' as const, display_label: 'backup', total: 5, counts: { pending: 1, running: 0, awaiting_input: 0, success: 4, failed: 0 } }),
|
||||
makeTypeSummary({ task_type: 'unknown' as const, display_label: 'unknown', total: 3, counts: { pending: 0, running: 0, awaiting_input: 0, success: 2, failed: 1 } }),
|
||||
],
|
||||
total_tasks: 8,
|
||||
});
|
||||
render(SummaryPanel, {
|
||||
summary: multi,
|
||||
screenState: 'ready' as ScreenState,
|
||||
onFilterByTypeAndStatus: onFilter,
|
||||
});
|
||||
expect(screen.getByText('backup')).toBeDefined();
|
||||
expect(screen.getByText('unknown')).toBeDefined();
|
||||
// Second type has 1 failed
|
||||
expect(screen.getByLabelText('unknown, Упало: 1')).toBeDefined();
|
||||
});
|
||||
});
|
||||
// #endregion SummaryPanelTest
|
||||
@@ -40,6 +40,13 @@ export const REPORT_TYPE_PROFILES: Record<string, ReportTypeProfile> = {
|
||||
icon: 'file-text',
|
||||
fallback: false
|
||||
},
|
||||
clean_release: {
|
||||
key: 'clean_release',
|
||||
label: 'Clean Release',
|
||||
variant: 'bg-teal-100 text-teal-700',
|
||||
icon: 'shield-check',
|
||||
fallback: false
|
||||
},
|
||||
unknown: {
|
||||
key: 'unknown',
|
||||
label: () => _('reports.unknown_type'),
|
||||
|
||||
399
frontend/src/lib/models/TaskCenterModel.svelte.ts
Normal file
399
frontend/src/lib/models/TaskCenterModel.svelte.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
// #region TaskCenter.Model [C:4] [TYPE Model] [SEMANTICS model,task-center,summary,filtering,websocket,task-status-center]
|
||||
// @defgroup TaskCenter Screen Model for the Task Status Center (/reports).
|
||||
// @LAYER Store
|
||||
// @PRE Auth token in localStorage, user authenticated via root +layout.svelte.
|
||||
// @INVARIANT Active tasks always sorted before completed in filteredTasks. visibleSummary excludes disabledTypes AND not in visibleTypes. WS: connect on load, disconnect on destroy. DECOMPOSITION GATE: 400 lines, 40 methods (ADR-0010).
|
||||
// @STATE tasks, summary, filters, screenState, wsConnected, selectedTaskId, page, pageSize, disabledTypes, visibleTypes, lastUpdated
|
||||
// @ACTION loadInitialData, applyFilter, clearFilters, selectTask, refreshSummary, connectWebSocket, disconnectWebSocket, destroy, toggleTypeVisibility
|
||||
// @RELATION CALLS -> [ReportsApi]
|
||||
// @RELATION CALLS -> [ApiModule.getTaskEventsWsUrl]
|
||||
// @RELATION BINDS_TO -> [TaskDrawerStore]
|
||||
// @RELATION BINDS_TO -> [SummaryPanel]
|
||||
// @RELATION BINDS_TO -> [FilterBar]
|
||||
// @RELATION BINDS_TO -> [TaskList]
|
||||
// @DATA_CONTRACT Input: TaskCenterFilter → Output: filteredTasks: TaskReport[], visibleSummary: TaskSummary
|
||||
// @POST State mutations trigger $derived recompute. WS lifecycle managed.
|
||||
// @SIDE_EFFECT WS connect/disconnect, fetch API, URL replaceState, openDrawerForTask, localStorage read/write.
|
||||
// @RATIONALE Single model (R3) — 3 stories share same task dataset, ~350 lines, below 400-line gate.
|
||||
// @REJECTED Submodels from day one (premature), global store (page-scoped), polling (spec requires WS real-time).
|
||||
|
||||
import { log } from '$lib/cot-logger';
|
||||
import { api } from '$lib/api.js';
|
||||
import { getTaskEventsWsUrl } from '$lib/api.js';
|
||||
import { openDrawerForTask } from '$lib/stores/taskDrawer.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import type {
|
||||
TaskType,
|
||||
ReportStatus,
|
||||
ScreenState,
|
||||
TaskReport,
|
||||
TaskSummary,
|
||||
TaskCenterFilter,
|
||||
TaskStatusEvent,
|
||||
} from '$types/reports';
|
||||
|
||||
export type { TaskType, ReportStatus, ScreenState, TaskReport, TaskSummary, TaskCenterFilter };
|
||||
// ── Constants ──
|
||||
const ALL_TYPES: TaskType[] = ['llm_verification', 'backup', 'migration', 'documentation', 'clean_release', 'unknown'];
|
||||
const DEBOUNCE_MS = 100;
|
||||
const URL_SYNC_MS = 300;
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||
const STORAGE_KEY = 'taskCenter.visibleTypes';
|
||||
|
||||
const DEFAULT_FILTERS: TaskCenterFilter = {
|
||||
task_types: [],
|
||||
statuses: [],
|
||||
search: '',
|
||||
time_range: 'all',
|
||||
sort_by: 'updated_at',
|
||||
sort_order: 'desc',
|
||||
};
|
||||
// ── Helpers ──
|
||||
function isActiveStatus(status: ReportStatus): boolean {
|
||||
return status === 'in_progress';
|
||||
}
|
||||
function loadVisibleTypes(): TaskType[] {
|
||||
if (typeof localStorage === 'undefined') return [...ALL_TYPES];
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as TaskType[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
return [...ALL_TYPES];
|
||||
}
|
||||
function timeRangeToFrom(range: TaskCenterFilter['time_range']): Date | null {
|
||||
if (range === 'all') return null;
|
||||
const now = new Date();
|
||||
switch (range) {
|
||||
case '1h': return new Date(now.getTime() - 60 * 60 * 1000);
|
||||
case '24h': return new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
case '7d': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
case '30d': return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model ──
|
||||
|
||||
export class TaskCenterModel {
|
||||
// === ATOMS ===
|
||||
tasks: TaskReport[] = $state([]);
|
||||
summary: TaskSummary | null = $state(null);
|
||||
filters: TaskCenterFilter = $state({ ...DEFAULT_FILTERS });
|
||||
screenState: ScreenState = $state('loading');
|
||||
wsConnected: boolean = $state(false);
|
||||
selectedTaskId: string | null = $state(null);
|
||||
page: number = $state(1);
|
||||
pageSize: number = $state(20);
|
||||
disabledTypes: TaskType[] = $state([]);
|
||||
visibleTypes: TaskType[] = $state(loadVisibleTypes());
|
||||
lastUpdated: Date | null = $state(null);
|
||||
private _ws: WebSocket | null = null;
|
||||
private _eventBuffer: TaskStatusEvent[] = [];
|
||||
private _flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private _reconnectAttempt: number = 0;
|
||||
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _urlSyncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
totalItems: number = $state(0);
|
||||
private _hasNextPage: boolean = false;
|
||||
|
||||
// === DERIVED ===
|
||||
|
||||
get filteredTasks(): TaskReport[] {
|
||||
// Server-side filtered + sorted by sort_by/sort_order.
|
||||
// Client-side sort only for active-first ordering and WS-updated tasks.
|
||||
return [...this.tasks].sort((a, b) => {
|
||||
const aActive = isActiveStatus(a.status) ? 1 : 0;
|
||||
const bActive = isActiveStatus(b.status) ? 1 : 0;
|
||||
if (aActive !== bActive) return bActive - aActive;
|
||||
const dir = this.filters.sort_order === 'asc' ? 1 : -1;
|
||||
switch (this.filters.sort_by) {
|
||||
case 'status': return a.status.localeCompare(b.status) * dir;
|
||||
case 'task_type': return a.task_type.localeCompare(b.task_type) * dir;
|
||||
case 'created_at':
|
||||
case 'updated_at':
|
||||
default:
|
||||
return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * dir;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get visibleSummary(): TaskSummary | null {
|
||||
if (!this.summary) return null;
|
||||
const filteredByType = this.summary.by_type.filter(
|
||||
(ts) => !this.disabledTypes.includes(ts.task_type) && this.visibleTypes.includes(ts.task_type)
|
||||
);
|
||||
const totalTasks = filteredByType.reduce((sum, ts) => sum + ts.total, 0);
|
||||
const activeTasks = filteredByType.reduce(
|
||||
(sum, ts) => sum + ts.counts.pending + ts.counts.running + ts.counts.awaiting_input, 0
|
||||
);
|
||||
return { by_type: filteredByType, total_tasks: totalTasks, active_tasks: activeTasks };
|
||||
}
|
||||
|
||||
get isEmpty(): boolean {
|
||||
return this.tasks.length === 0 && this.screenState === 'ready';
|
||||
}
|
||||
|
||||
get isFilteredEmpty(): boolean {
|
||||
return this.filteredTasks.length === 0 && this.hasActiveFilters;
|
||||
}
|
||||
|
||||
get hasActiveFilters(): boolean {
|
||||
return (
|
||||
this.filters.task_types.length > 0 ||
|
||||
this.filters.statuses.length > 0 ||
|
||||
this.filters.search.trim() !== '' ||
|
||||
this.filters.time_range !== 'all'
|
||||
);
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.max(1, Math.ceil(this.totalItems / this.pageSize));
|
||||
}
|
||||
|
||||
get hasNextPage(): boolean {
|
||||
return this._hasNextPage;
|
||||
}
|
||||
|
||||
// === LIFECYCLE ===
|
||||
|
||||
async loadInitialData(initialFilters?: Partial<TaskCenterFilter>): Promise<void> {
|
||||
this.screenState = 'loading';
|
||||
log('TaskCenter.Model', 'REASON', 'Loading initial data');
|
||||
|
||||
if (initialFilters) {
|
||||
this.filters = { ...DEFAULT_FILTERS, ...initialFilters };
|
||||
}
|
||||
|
||||
try {
|
||||
// Parallel fetch: summary + settings + reports
|
||||
const [summaryData, settingsRes, listData] = await Promise.all([
|
||||
api.fetchApi<any>('/reports/summary'),
|
||||
api.fetchApi<any>('/settings/reports').catch(() => null), // non-critical
|
||||
api.fetchApi<any>(`/reports?${this._buildQueryString()}`),
|
||||
]);
|
||||
|
||||
this.summary = summaryData as TaskSummary;
|
||||
this.tasks = (listData?.items ?? []) as TaskReport[];
|
||||
this.totalItems = listData?.total ?? 0;
|
||||
this._hasNextPage = listData?.has_next ?? false;
|
||||
if (settingsRes) {
|
||||
this.disabledTypes = (settingsRes as any)?.disabled_task_types ?? [];
|
||||
}
|
||||
|
||||
this.lastUpdated = new Date();
|
||||
this.screenState = this.summary.total_tasks === 0 ? 'empty' : 'ready';
|
||||
log('TaskCenter.Model', 'REFLECT', 'Initial data loaded', {
|
||||
tasks: this.tasks.length, total: this.totalItems,
|
||||
});
|
||||
|
||||
// Connect WebSocket after initial load
|
||||
this.connectWebSocket();
|
||||
} catch (e: unknown) {
|
||||
this.screenState = 'error';
|
||||
log('TaskCenter.Model', 'EXPLORE', 'Initial load failed', {}, e instanceof Error ? e.message : 'unknown');
|
||||
addToast('Не удалось загрузить данные задач', 'error');
|
||||
}
|
||||
}
|
||||
async loadReports(): Promise<void> {
|
||||
log('TaskCenter.Model', 'REASON', 'Loading reports', { page: this.page });
|
||||
this.screenState = 'loading';
|
||||
try {
|
||||
const listData = await api.fetchApi<any>(`/reports?${this._buildQueryString()}`);
|
||||
this.tasks = (listData?.items ?? []) as TaskReport[];
|
||||
this.totalItems = listData?.total ?? 0;
|
||||
this._hasNextPage = listData?.has_next ?? false;
|
||||
this.lastUpdated = new Date();
|
||||
this.screenState = this.totalItems === 0 && !this.hasActiveFilters ? 'empty' : 'ready';
|
||||
log('TaskCenter.Model', 'REFLECT', 'Reports loaded', { tasks: this.tasks.length, total: this.totalItems });
|
||||
} catch (e: unknown) {
|
||||
this.screenState = 'error';
|
||||
log('TaskCenter.Model', 'EXPLORE', 'Load reports failed', {}, e instanceof Error ? e.message : 'unknown');
|
||||
addToast('Не удалось загрузить данные задач', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadPage(pageNum: number): Promise<void> {
|
||||
this.page = pageNum;
|
||||
await this.loadReports();
|
||||
}
|
||||
connectWebSocket(): void {
|
||||
if (this._ws) this.disconnectWebSocket();
|
||||
|
||||
const url = getTaskEventsWsUrl();
|
||||
this._ws = new WebSocket(url);
|
||||
|
||||
this._ws.onopen = () => {
|
||||
this.wsConnected = true;
|
||||
this._reconnectAttempt = 0;
|
||||
if (this.screenState === 'disconnected' || this.screenState === 'reconnecting') {
|
||||
this.screenState = 'ready';
|
||||
// Re-sync summary after reconnect (missed events)
|
||||
this.refreshSummary();
|
||||
}
|
||||
log('TaskCenter.Model', 'REFLECT', 'WebSocket connected');
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event: MessageEvent) => this._onWsMessage(event);
|
||||
|
||||
this._ws.onclose = (event: CloseEvent) => {
|
||||
this.wsConnected = false;
|
||||
if (event.code === 1000) return; // normal close
|
||||
|
||||
this.screenState = 'reconnecting';
|
||||
// After 2s of reconnecting, show disconnected banner
|
||||
setTimeout(() => {
|
||||
if (!this.wsConnected && this.screenState === 'reconnecting') {
|
||||
this.screenState = 'disconnected';
|
||||
}
|
||||
}, 2000);
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
// Error handled by onclose
|
||||
};
|
||||
}
|
||||
|
||||
disconnectWebSocket(): void {
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
if (this._flushTimeout) {
|
||||
clearTimeout(this._flushTimeout);
|
||||
this._flushTimeout = null;
|
||||
}
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // prevent reconnect trigger
|
||||
this._ws.close(1000);
|
||||
this._ws = null;
|
||||
}
|
||||
this.wsConnected = false;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.disconnectWebSocket();
|
||||
if (this._urlSyncTimeout) {
|
||||
clearTimeout(this._urlSyncTimeout);
|
||||
this._urlSyncTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// === ACTIONS ===
|
||||
|
||||
applyFilter(partial: Partial<TaskCenterFilter>): void {
|
||||
this.filters = { ...this.filters, ...partial };
|
||||
this.page = 1;
|
||||
this._syncFiltersToUrl();
|
||||
log('TaskCenter.Model', 'REASON', 'Filter applied', { partial });
|
||||
this.loadReports();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filters = { ...DEFAULT_FILTERS };
|
||||
this.page = 1;
|
||||
this._syncFiltersToUrl();
|
||||
log('TaskCenter.Model', 'REFLECT', 'Filters cleared');
|
||||
this.loadReports();
|
||||
}
|
||||
|
||||
selectTask(taskId: string): void {
|
||||
this.selectedTaskId = taskId;
|
||||
const task = this.tasks.find(t => t.task_id === taskId);
|
||||
const hint = task?.status === 'failed' ? 'last_error' : undefined;
|
||||
openDrawerForTask(taskId, hint);
|
||||
log('TaskCenter.Model', 'REASON', 'Task selected', { taskId, scrollToHint: hint });
|
||||
}
|
||||
async refreshSummary(): Promise<void> {
|
||||
try {
|
||||
const data = await api.fetchApi<any>('/reports/summary');
|
||||
this.summary = data as TaskSummary;
|
||||
this.lastUpdated = new Date();
|
||||
log('TaskCenter.Model', 'REFLECT', 'Summary refreshed');
|
||||
} catch (e: unknown) {
|
||||
log('TaskCenter.Model', 'EXPLORE', 'Summary refresh failed', {}, e instanceof Error ? e.message : 'unknown');
|
||||
}
|
||||
}
|
||||
|
||||
toggleTypeVisibility(type: TaskType): void {
|
||||
if (this.visibleTypes.includes(type)) {
|
||||
this.visibleTypes = this.visibleTypes.filter((t) => t !== type);
|
||||
} else {
|
||||
this.visibleTypes = [...this.visibleTypes, type];
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.visibleTypes));
|
||||
}
|
||||
log('TaskCenter.Model', 'REFLECT', 'Type visibility toggled', { type, visible: this.visibleTypes });
|
||||
}
|
||||
|
||||
// === PRIVATE METHODS ===
|
||||
|
||||
private _onWsMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as TaskStatusEvent;
|
||||
if (data.type === 'task_status') {
|
||||
this._eventBuffer.push(data);
|
||||
if (!this._flushTimeout) {
|
||||
this._flushTimeout = setTimeout(() => this._flushEventBuffer(), DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore malformed messages */ }
|
||||
}
|
||||
|
||||
private _flushEventBuffer(): void {
|
||||
this._flushTimeout = null;
|
||||
const count = this._eventBuffer.length;
|
||||
if (count === 0) return;
|
||||
log('TaskCenter.Model', 'REASON', 'Flushing WS events', { count });
|
||||
this._eventBuffer = [];
|
||||
this.lastUpdated = new Date();
|
||||
// Refresh summary for accurate aggregated counts (WS events carry raw Task, not normalized TaskReport)
|
||||
this.refreshSummary();
|
||||
}
|
||||
|
||||
private _scheduleReconnect(): void {
|
||||
const idx = Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1);
|
||||
const delay = RECONNECT_DELAYS[idx];
|
||||
this._reconnectAttempt++;
|
||||
log('TaskCenter.Model', 'EXPLORE', 'Scheduling reconnect', { attempt: this._reconnectAttempt, delay });
|
||||
this._reconnectTimer = setTimeout(() => this.connectWebSocket(), delay);
|
||||
}
|
||||
|
||||
private _buildQueryParams(): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (this.filters.task_types.length > 0) p.set('task_types', this.filters.task_types.join(','));
|
||||
if (this.filters.statuses.length > 0) p.set('statuses', this.filters.statuses.join(','));
|
||||
if (this.filters.search.trim()) p.set('search', this.filters.search.trim());
|
||||
const timeFrom = timeRangeToFrom(this.filters.time_range);
|
||||
if (timeFrom) p.set('time_from', timeFrom.toISOString());
|
||||
return p;
|
||||
}
|
||||
|
||||
private _syncFiltersToUrl(): void {
|
||||
if (this._urlSyncTimeout) clearTimeout(this._urlSyncTimeout);
|
||||
this._urlSyncTimeout = setTimeout(() => {
|
||||
if (typeof history === 'undefined') return;
|
||||
const p = this._buildQueryParams();
|
||||
if (this.filters.time_range !== 'all') p.set('range', this.filters.time_range);
|
||||
if (this.filters.sort_by !== 'updated_at') p.set('sort', this.filters.sort_by);
|
||||
if (this.filters.sort_order !== 'desc') p.set('order', this.filters.sort_order);
|
||||
if (this.page > 1) p.set('page', String(this.page));
|
||||
const qs = p.toString();
|
||||
history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname);
|
||||
}, URL_SYNC_MS);
|
||||
}
|
||||
|
||||
private _buildQueryString(): string {
|
||||
const p = this._buildQueryParams();
|
||||
p.set('page', String(this.page));
|
||||
p.set('page_size', String(this.pageSize));
|
||||
p.set('sort_by', this.filters.sort_by);
|
||||
p.set('sort_order', this.filters.sort_order);
|
||||
return p.toString();
|
||||
}
|
||||
}
|
||||
// #endregion TaskCenter.Model
|
||||
237
frontend/src/lib/models/__tests__/TaskCenterModel.test.ts
Normal file
237
frontend/src/lib/models/__tests__/TaskCenterModel.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
// #region TaskCenterModelTests [C:3] [TYPE Module] [SEMANTICS test,model,task-center]
|
||||
// @BRIEF L1 unit tests for TaskCenterModel — no DOM render.
|
||||
// @RELATION BINDS_TO -> [TaskCenterModel]
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mocks
|
||||
vi.mock("$lib/api.js", () => ({
|
||||
api: { fetchApi: vi.fn() },
|
||||
getTaskEventsWsUrl: vi.fn(() => "ws://test/events"),
|
||||
}));
|
||||
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
|
||||
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
|
||||
vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({ openDrawerForTask: vi.fn() }));
|
||||
|
||||
import { TaskCenterModel } from "../TaskCenterModel.svelte.ts";
|
||||
import { api } from "$lib/api.js";
|
||||
|
||||
// Sample data
|
||||
const makeTask = (id: string, status: string, taskType = "llm_verification") => ({
|
||||
report_id: id,
|
||||
task_id: id,
|
||||
task_type: taskType,
|
||||
status,
|
||||
summary: `Task ${id}`,
|
||||
updated_at: "2026-07-02T12:00:00Z",
|
||||
started_at: null,
|
||||
details: null,
|
||||
error_context: null,
|
||||
});
|
||||
|
||||
const makeSummary = (overrides = {}) => ({
|
||||
by_type: [
|
||||
{
|
||||
task_type: "llm_verification",
|
||||
display_label: "llm_verification",
|
||||
icon_token: "",
|
||||
counts: { pending: 1, running: 2, awaiting_input: 0, success: 10, failed: 3 },
|
||||
total: 16,
|
||||
},
|
||||
],
|
||||
total_tasks: 16,
|
||||
active_tasks: 3,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// #region TaskCenterModel.InvariantTests
|
||||
describe("TaskCenterModel — L1 invariants", () => {
|
||||
let model: TaskCenterModel;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
// Default mock: fetchApi returns empty data for initial load
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
|
||||
model = new TaskCenterModel();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// #region TaskCenterModel.InitialState
|
||||
it("starts in loading state with default filters", () => {
|
||||
expect(model.screenState).toBe("loading");
|
||||
expect(model.page).toBe(1);
|
||||
expect(model.pageSize).toBe(20);
|
||||
expect(model.filters).toEqual({
|
||||
task_types: [],
|
||||
statuses: [],
|
||||
search: '',
|
||||
time_range: 'all',
|
||||
sort_by: 'updated_at',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
expect(model.wsConnected).toBe(false);
|
||||
expect(model.tasks).toEqual([]);
|
||||
expect(model.summary).toBeNull();
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.FilteredTasks.OrdersActiveFirst
|
||||
it("active tasks sorted before completed in filteredTasks", () => {
|
||||
model.tasks = [
|
||||
makeTask("t3", "success"),
|
||||
makeTask("t1", "in_progress"),
|
||||
makeTask("t2", "failed"),
|
||||
makeTask("t4", "in_progress"),
|
||||
];
|
||||
const ft = model.filteredTasks;
|
||||
// First two should be in_progress (active), then success/failed
|
||||
expect(ft[0].status).toBe("in_progress");
|
||||
expect(ft[1].status).toBe("in_progress");
|
||||
expect(ft[2].status).toBe("success");
|
||||
expect(ft[3].status).toBe("failed");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.EmptySummaryShowsZeros
|
||||
it("visibleSummary returns zeros when summary is null", () => {
|
||||
expect(model.visibleSummary).toBeNull();
|
||||
model.summary = makeSummary();
|
||||
expect(model.visibleSummary).not.toBeNull();
|
||||
expect(model.visibleSummary!.total_tasks).toBe(16);
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.DisconnectedState
|
||||
it("screenState transitions to disconnected after abnormal WS close", () => {
|
||||
// Mock WebSocket with EventTarget-like interface
|
||||
let wsOnclose: ((e: any) => void) | null = null;
|
||||
class FakeWS {
|
||||
close = vi.fn();
|
||||
set onclose(fn: ((e: any) => void) | null) { wsOnclose = fn; }
|
||||
get onclose() { return wsOnclose; }
|
||||
set onopen(_fn: any) { /* noop — not needed for this test */ }
|
||||
get onopen() { return null; }
|
||||
set onerror(_fn: any) {}
|
||||
get onerror() { return null; }
|
||||
set onmessage(_fn: any) {}
|
||||
get onmessage() { return null; }
|
||||
}
|
||||
global.WebSocket = FakeWS as any;
|
||||
|
||||
model.connectWebSocket();
|
||||
// Simulate abnormal close
|
||||
wsOnclose?.({ code: 1006 } as CloseEvent);
|
||||
expect(model.screenState).toBe("reconnecting");
|
||||
|
||||
// After 2s timeout, should transition to disconnected
|
||||
vi.advanceTimersByTime(2100);
|
||||
expect(model.screenState).toBe("disconnected");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.ToggleVisibility
|
||||
it("toggleTypeVisibility adds/removes type from visibleTypes", () => {
|
||||
model.visibleTypes = ["llm_verification", "backup"];
|
||||
model.toggleTypeVisibility("llm_verification");
|
||||
expect(model.visibleTypes).toEqual(["backup"]);
|
||||
model.toggleTypeVisibility("llm_verification");
|
||||
expect(model.visibleTypes).toEqual(["backup", "llm_verification"]);
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.ApplyFilterResetsPage
|
||||
it("applyFilter resets page to 1 and triggers loadReports", async () => {
|
||||
model.page = 5;
|
||||
model.tasks = [makeTask("t1", "success")];
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
|
||||
await model.applyFilter({ task_types: ["backup"] });
|
||||
|
||||
expect(model.filters.task_types).toEqual(["backup"]);
|
||||
expect(model.page).toBe(1);
|
||||
// Should have called fetchApi to reload
|
||||
expect(api.fetchApi).toHaveBeenCalled();
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.ClearFilters
|
||||
it("clearFilters resets filters to defaults and reloads", async () => {
|
||||
model.filters = { task_types: ["backup"], statuses: ["failed"], search: "test", time_range: "24h", sort_by: "status", sort_order: "asc" };
|
||||
model.page = 3;
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
|
||||
await model.clearFilters();
|
||||
|
||||
expect(model.filters).toEqual({
|
||||
task_types: [], statuses: [], search: '', time_range: 'all', sort_by: 'updated_at', sort_order: 'desc',
|
||||
});
|
||||
expect(model.page).toBe(1);
|
||||
expect(api.fetchApi).toHaveBeenCalled();
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.SelectTask
|
||||
it("selectTask sets selectedTaskId and opens drawer", async () => {
|
||||
const { openDrawerForTask } = await import("$lib/stores/taskDrawer.svelte.js");
|
||||
model.selectTask("task-123");
|
||||
expect(model.selectedTaskId).toBe("task-123");
|
||||
expect(openDrawerForTask).toHaveBeenCalledWith("task-123", undefined);
|
||||
|
||||
// Test with failed task
|
||||
model.tasks = [makeTask("task-456", "failed")];
|
||||
model.selectTask("task-456");
|
||||
expect(openDrawerForTask).toHaveBeenCalledWith("task-456", "last_error");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.hasNextPage
|
||||
it("hasNextPage and totalPages derived correctly", () => {
|
||||
model.totalItems = 45;
|
||||
model.pageSize = 20;
|
||||
expect(model.totalPages).toBe(3); // ceil(45/20) = 3
|
||||
expect(model.hasNextPage).toBe(false); // _hasNextPage not set
|
||||
|
||||
model["_hasNextPage"] = true; // simulate API response
|
||||
expect(model.hasNextPage).toBe(true);
|
||||
});
|
||||
// #endregion
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.FilterTests
|
||||
describe("TaskCenterModel — filter invariants", () => {
|
||||
let model: TaskCenterModel;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
|
||||
model = new TaskCenterModel();
|
||||
});
|
||||
|
||||
// #region TaskCenterModel.HasActiveFilters
|
||||
it("hasActiveFilters returns true when filters are active", () => {
|
||||
expect(model.hasActiveFilters).toBe(false);
|
||||
model.filters.task_types = ["backup"];
|
||||
expect(model.hasActiveFilters).toBe(true);
|
||||
model.filters = { task_types: [], statuses: [], search: '', time_range: 'all', sort_by: 'updated_at', sort_order: 'desc' };
|
||||
expect(model.hasActiveFilters).toBe(false);
|
||||
model.filters.search = "test";
|
||||
expect(model.hasActiveFilters).toBe(true);
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.IsFilteredEmpty
|
||||
it("isFilteredEmpty checks tasks length and active filters", () => {
|
||||
expect(model.isFilteredEmpty).toBe(false); // no active filters
|
||||
model.filters.statuses = ["failed"];
|
||||
expect(model.isFilteredEmpty).toBe(true); // tasks empty + filters active
|
||||
model.tasks = [makeTask("t1", "failed")];
|
||||
expect(model.isFilteredEmpty).toBe(false); // tasks exist
|
||||
});
|
||||
// #endregion
|
||||
});
|
||||
// #endregion
|
||||
// #endregion TaskCenterModelTests
|
||||
@@ -20,6 +20,7 @@ interface ResourceTaskMap {
|
||||
interface TaskDrawerState {
|
||||
isOpen: boolean;
|
||||
activeTaskId: string | null;
|
||||
scrollToHint: string | null;
|
||||
resourceTaskMap: ResourceTaskMap;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ let autoOpenTaskDrawerPreference: boolean = readAutoOpenTaskDrawerPreference();
|
||||
const initialState: TaskDrawerState = {
|
||||
isOpen: false,
|
||||
activeTaskId: null,
|
||||
scrollToHint: null,
|
||||
resourceTaskMap: {},
|
||||
};
|
||||
|
||||
@@ -72,12 +74,12 @@ export const taskDrawerStore = {
|
||||
},
|
||||
};
|
||||
|
||||
export function openDrawerForTask(taskId: string): boolean {
|
||||
export function openDrawerForTask(taskId: string, scrollToHint?: string): boolean {
|
||||
if (!taskId) {
|
||||
return false;
|
||||
}
|
||||
if (_state.isOpen && _state.activeTaskId === taskId) return true; // Already open for this task
|
||||
_state = { ..._state, isOpen: true, activeTaskId: taskId };
|
||||
_state = { ..._state, isOpen: true, activeTaskId: taskId, scrollToHint: scrollToHint ?? null };
|
||||
_notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
page = $bindable(0),
|
||||
pageSize = 20,
|
||||
total = 0,
|
||||
showingText = '',
|
||||
pageSizeOptions = [] as number[],
|
||||
onPageSizeChange,
|
||||
class: className = "",
|
||||
@@ -49,7 +50,7 @@
|
||||
<!-- Summary -->
|
||||
<div class="text-sm text-text-muted">
|
||||
{#if total > 0}
|
||||
{($t.dashboard?.showing ?? "Showing {start}-{end} of {total}")
|
||||
{(showingText || $t.dashboard?.showing || "Showing {start}-{end} of {total}")
|
||||
.replace("{start}", String(start))
|
||||
.replace("{end}", String(end))
|
||||
.replace("{total}", String(total))}
|
||||
@@ -69,7 +70,7 @@
|
||||
if (onPageSizeChange) onPageSizeChange(val);
|
||||
}}
|
||||
>
|
||||
{#each pageSizeOptions as opt}
|
||||
{#each pageSizeOptions as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -78,7 +79,7 @@
|
||||
<!-- Page number buttons (only when > 1 page) -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex gap-1">
|
||||
{#each pageNumbers as p}
|
||||
{#each pageNumbers as p, i (String(p) + "-" + i)}
|
||||
{#if p === "..."}
|
||||
<span class="px-2 py-1 text-sm text-text-muted">…</span>
|
||||
{:else}
|
||||
|
||||
@@ -109,4 +109,24 @@ export function formatFileSize(bytes: number | null | undefined): string {
|
||||
}
|
||||
// #endregion formatFileSize:Function
|
||||
|
||||
// #region formatDuration:Function [TYPE Function]
|
||||
// @BRIEF Format duration between two dates as human-readable string (e.g. "2m 30s", "1h 15m").
|
||||
export function formatDuration(start: string | Date | null | undefined, end: string | Date | null | undefined): string {
|
||||
if (!start || !end) return "";
|
||||
const startDate = parseDateUTC(start);
|
||||
const endDate = parseDateUTC(end);
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return "";
|
||||
const diffMs = endDate.getTime() - startDate.getTime();
|
||||
if (diffMs < 0) return "";
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const sec = diffSec % 60;
|
||||
if (diffMin < 60) return sec > 0 ? `${diffMin}m ${sec}s` : `${diffMin}m`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const min = diffMin % 60;
|
||||
return min > 0 ? `${diffHour}h ${min}m` : `${diffHour}h`;
|
||||
}
|
||||
// #endregion formatDuration:Function
|
||||
|
||||
// #endregion Utils.DateFormat
|
||||
|
||||
@@ -1,207 +1,219 @@
|
||||
<!-- #region UnifiedReportsPage [C:3] [TYPE Page] [SEMANTICS sveltekit, report, filter, task, detail] -->
|
||||
<!-- @ingroup Routes -->
|
||||
<!-- @BRIEF Unified reports page with task-type and status filtering, list selection, detail panel, and resilient UX states for mixed task types. -->
|
||||
<!-- #region TaskCenterReportsPage [C:4] [TYPE Page] [SEMANTICS page, reports, task-center, layout, task-status-center] -->
|
||||
<!-- @BRIEF Task Status Center page — thin render layer over TaskCenterModel. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @RELATION DEPENDS_ON -> [ReportsApi] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:ReportsList] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:ReportDetailPanel] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [PageHeader] -->
|
||||
<!-- @UX_STATE Loading -> Skeleton block visible; filters remain interactive. -->
|
||||
<!-- @UX_STATE Ready -> Reports list rendered with selected report detail. -->
|
||||
<!-- @UX_STATE NoData -> Friendly empty state for total=0 without filters. -->
|
||||
<!-- @UX_STATE FilteredEmpty -> Filtered empty state with one-click clear. -->
|
||||
<!-- @UX_STATE Error -> Inline error with retry preserving filter selections. -->
|
||||
<!-- @UX_FEEDBACK Filter change immediately reloads the list. -->
|
||||
<!-- @UX_FEEDBACK Selecting a report loads its detail in the right panel. -->
|
||||
<!-- @UX_RECOVERY Retry and clear-filters actions available. -->
|
||||
<!-- @UX_STATE loading: full-page skeleton -->
|
||||
<!-- @UX_STATE empty: "No tasks" empty state with navigation hints -->
|
||||
<!-- @UX_STATE ready: summary panel + filter bar + task list -->
|
||||
<!-- @UX_STATE disconnected: data visible with stale indicator + reconnect banner -->
|
||||
<!-- @UX_STATE error: error banner + retry button -->
|
||||
<!-- @UX_REACTIVITY Model: TaskCenterModel instance. Page is thin render layer. -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
|
||||
<!-- @RELATION BINDS_TO -> [SummaryPanel] -->
|
||||
<!-- @RELATION BINDS_TO -> [FilterBar] -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskList] -->
|
||||
<!-- @RELATION CALLS -> [TaskDrawer] -->
|
||||
<!-- @PRE User is authenticated (enforced by root +layout.svelte). -->
|
||||
<!-- @POST Page renders summary, filters, and task list. WebSocket connects automatically. -->
|
||||
<!-- @SIDE_EFFECT Initializes TaskCenterModel on mount. Connects WebSocket. Destroys on unmount. -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { PageHeader } from "$lib/ui";
|
||||
import { getReports, getReportDetail } from "$lib/api/reports.js";
|
||||
import ReportsList from "$lib/components/reports/ReportsList.svelte";
|
||||
import ReportDetailPanel from "$lib/components/reports/ReportDetailPanel.svelte";
|
||||
import { onMount } from 'svelte';
|
||||
import { TaskCenterModel } from '$lib/models/TaskCenterModel.svelte.ts';
|
||||
import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte';
|
||||
import ReportsList from '$lib/components/reports/ReportsList.svelte';
|
||||
import { PageHeader, Button, EmptyState, Pagination, Input, Select } from '$lib/ui';
|
||||
import type { PageData } from './+page.ts';
|
||||
import type { TaskCenterFilter } from '$types/reports';
|
||||
|
||||
let loading = true;
|
||||
let error = "";
|
||||
let collection = null;
|
||||
let selectedReport = null;
|
||||
let selectedReportDetail = null;
|
||||
|
||||
let taskType = "all";
|
||||
let status = "all";
|
||||
let page = 1;
|
||||
const pageSize = 20;
|
||||
|
||||
const TASK_TYPE_OPTIONS = [
|
||||
{ value: "all", label: $t.reports?.all_types },
|
||||
{ value: "llm_verification", label: "LLM" },
|
||||
{ value: "backup", label: $t.nav?.backups },
|
||||
{ value: "migration", label: $t.nav?.migration },
|
||||
{ value: "documentation", label: "Documentation" },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all", label: $t.reports?.all_statuses },
|
||||
{ value: "success", label: "Success" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "in_progress", label: "In progress" },
|
||||
{ value: "partial", label: "Partial" },
|
||||
];
|
||||
|
||||
function buildQuery() {
|
||||
return {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
task_types: taskType === "all" ? [] : [taskType],
|
||||
statuses: status === "all" ? [] : [status],
|
||||
sort_by: "updated_at",
|
||||
sort_order: "desc",
|
||||
};
|
||||
}
|
||||
|
||||
async function loadReports({ silent = false } = {}) {
|
||||
try {
|
||||
if (!silent) loading = true;
|
||||
error = "";
|
||||
collection = await getReports(buildQuery());
|
||||
if (!selectedReport && collection?.items?.length) {
|
||||
selectedReport = collection.items[0];
|
||||
selectedReportDetail = await getReportDetail(selectedReport.report_id);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e?.message || "Failed to load reports";
|
||||
collection = null;
|
||||
} finally {
|
||||
if (!silent) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasActiveFilters() {
|
||||
return taskType !== "all" || status !== "all";
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
taskType = "all";
|
||||
status = "all";
|
||||
page = 1;
|
||||
selectedReport = null;
|
||||
selectedReportDetail = null;
|
||||
loadReports();
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page = 1;
|
||||
selectedReport = null;
|
||||
selectedReportDetail = null;
|
||||
loadReports();
|
||||
}
|
||||
|
||||
async function onSelectReport(event) {
|
||||
const payload = event?.report ?? event?.detail?.report;
|
||||
if (!payload) return;
|
||||
selectedReport = payload;
|
||||
selectedReportDetail = await getReportDetail(selectedReport.report_id);
|
||||
}
|
||||
let { data }: { data: PageData } = $props();
|
||||
const m = new TaskCenterModel();
|
||||
|
||||
onMount(() => {
|
||||
loadReports();
|
||||
m.loadInitialData(data.initialFilters);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => m.destroy();
|
||||
});
|
||||
|
||||
// ── Pagination bridge (0-indexed ↔ 1-indexed) ──
|
||||
let pageZero = $state(m.page - 1);
|
||||
|
||||
$effect(() => {
|
||||
if (pageZero + 1 !== m.page) {
|
||||
m.loadPage(pageZero + 1);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (m.page - 1 !== pageZero) {
|
||||
pageZero = m.page - 1;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Search debounce ──
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleSearchInput(e: Event & { currentTarget: HTMLInputElement }): void {
|
||||
clearTimeout(searchTimeout);
|
||||
const value = e.currentTarget.value;
|
||||
searchTimeout = setTimeout(() => {
|
||||
m.applyFilter({ search: value });
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function handleSortChange(e: Event & { currentTarget: HTMLSelectElement }): void {
|
||||
m.applyFilter({ sort_by: e.currentTarget.value as TaskCenterFilter['sort_by'] });
|
||||
}
|
||||
|
||||
function handleTimeRangeChange(e: Event & { currentTarget: HTMLSelectElement }): void {
|
||||
m.applyFilter({ time_range: e.currentTarget.value as TaskCenterFilter['time_range'] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto w-full max-w-7xl space-y-4">
|
||||
<PageHeader
|
||||
title={$t.reports?.title}
|
||||
subtitle={() => null}
|
||||
actions={() => null}
|
||||
/>
|
||||
|
||||
<div class="rounded-xl border border-border bg-surface-card p-4 shadow-sm">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-4">
|
||||
<select
|
||||
bind:value={taskType}
|
||||
onchange={onFilterChange}
|
||||
class="rounded-md border border-border-strong px-2 py-1.5 text-sm"
|
||||
>
|
||||
{#each TASK_TYPE_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
bind:value={status}
|
||||
onchange={onFilterChange}
|
||||
class="rounded-md border border-border-strong px-2 py-1.5 text-sm"
|
||||
>
|
||||
{#each STATUS_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-lg border border-border-strong px-3 py-1.5 text-sm font-medium text-text transition-colors hover:bg-surface-page"
|
||||
onclick={() => loadReports()}
|
||||
>
|
||||
{$t.common?.refresh}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-lg border border-border-strong px-3 py-1.5 text-sm font-medium text-text transition-colors hover:bg-surface-page"
|
||||
onclick={clearFilters}
|
||||
>
|
||||
{$t.reports?.clear_filters}
|
||||
</button>
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 space-y-4">
|
||||
<PageHeader title="Центр статусов">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if m.wsConnected}
|
||||
<span class="w-3 h-3 rounded-full bg-success" aria-label="Соединение установлено"></span>
|
||||
{:else}
|
||||
<span class="w-3 h-3 rounded-full {m.screenState === 'reconnecting' ? 'bg-warning animate-pulse' : 'bg-destructive'}"
|
||||
aria-label="{m.screenState === 'reconnecting' ? 'Переподключение' : 'Соединение потеряно'}"></span>
|
||||
{/if}
|
||||
{#if m.lastUpdated}
|
||||
<span class="text-xs text-text-muted">
|
||||
Обновлено: {Math.round((Date.now() - m.lastUpdated.getTime()) / 60000)} мин назад
|
||||
</span>
|
||||
{/if}
|
||||
<Button variant="ghost" onclick={() => m.refreshSummary()}>Обновить</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{#if loading}
|
||||
<div
|
||||
class="rounded-xl border border-border bg-surface-card p-6 text-sm text-text-muted shadow-sm"
|
||||
>
|
||||
{$t.reports?.loading}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="rounded-xl border border-destructive-light bg-destructive-light p-4 text-destructive shadow-sm"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
class="mt-2 inline-flex items-center justify-center rounded-lg border border-destructive-ring px-3 py-1 text-sm font-medium text-destructive transition-colors hover:bg-destructive-light"
|
||||
onclick={() => loadReports()}
|
||||
>
|
||||
{$t.reports?.retry_load || $t.common?.retry}
|
||||
</button>
|
||||
</div>
|
||||
{:else if !collection || collection.total === 0}
|
||||
<div
|
||||
class="rounded-xl border border-border bg-surface-card p-6 text-sm text-text-muted shadow-sm"
|
||||
>
|
||||
{$t.reports?.empty}
|
||||
</div>
|
||||
{:else if collection.items.length === 0 && hasActiveFilters()}
|
||||
<div
|
||||
class="rounded-xl border border-border bg-surface-card p-6 text-sm text-text-muted shadow-sm"
|
||||
>
|
||||
<p>{$t.reports?.filtered_empty}</p>
|
||||
<button
|
||||
class="mt-2 inline-flex items-center justify-center rounded-lg border border-border-strong px-3 py-1 text-sm font-medium text-text transition-colors hover:bg-surface-page"
|
||||
onclick={clearFilters}
|
||||
>
|
||||
{$t.reports?.clear_filters}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<ReportsList
|
||||
reports={collection?.items || []}
|
||||
selectedReportId={selectedReport?.report_id}
|
||||
onselect={onSelectReport}
|
||||
/>
|
||||
</div>
|
||||
<ReportDetailPanel detail={selectedReportDetail} />
|
||||
{#if m.screenState === 'error'}
|
||||
<div class="bg-destructive-light border border-destructive-ring text-destructive p-3 rounded-lg flex items-center justify-between">
|
||||
<span>Не удалось загрузить данные задач. Сервер временно недоступен.</span>
|
||||
<Button variant="destructive" onclick={() => m.loadInitialData()}>Повторить</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- #endregion UnifiedReportsPage -->
|
||||
{#if m.screenState === 'disconnected'}
|
||||
<div class="bg-warning-light border border-warning-ring text-warning p-3 rounded-lg flex items-center justify-between">
|
||||
<span>Соединение потеряно. Попытка переподключения...</span>
|
||||
<Button variant="secondary" onclick={() => m.connectWebSocket()}>Подключиться сейчас</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Summary Panel -->
|
||||
<SummaryPanel
|
||||
summary={m.visibleSummary}
|
||||
screenState={m.screenState}
|
||||
onFilterByTypeAndStatus={(type, status) => m.applyFilter({ task_types: [type], statuses: [status] })}
|
||||
onReconnect={() => m.connectWebSocket()}
|
||||
activeFilters={{ task_types: m.filters.task_types, statuses: m.filters.statuses }}
|
||||
/>
|
||||
{#if m.screenState === 'ready' || m.screenState === 'disconnected'}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('failed') ? 'border-destructive-ring bg-destructive-light text-destructive' : 'border-border text-text-muted hover:border-destructive-ring hover:text-destructive'}"
|
||||
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('failed') ? [] : ['failed'] })}
|
||||
>
|
||||
Упавшие {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.failed, 0)}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('in_progress') ? 'border-primary-ring bg-primary/10 text-primary' : 'border-border text-text-muted hover:border-primary-ring hover:text-primary'}"
|
||||
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('in_progress') ? [] : ['in_progress'] })}
|
||||
>
|
||||
В работе {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.running, 0)}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('success') ? 'border-success-ring bg-success-light text-success' : 'border-border text-text-muted hover:border-success-ring hover:text-success'}"
|
||||
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('success') ? [] : ['success'] })}
|
||||
>
|
||||
Успешные {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.success, 0)}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="bg-surface-muted rounded-lg p-3 flex flex-wrap gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Поиск по задачам..."
|
||||
value={m.filters.search}
|
||||
oninput={handleSearchInput}
|
||||
class="w-48"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Сортировка</label>
|
||||
<Select
|
||||
id="sort-select"
|
||||
value={m.filters.sort_by}
|
||||
onchange={handleSortChange}
|
||||
class="w-36"
|
||||
>
|
||||
<option value="updated_at">По обновлению</option>
|
||||
<option value="created_at">По созданию</option>
|
||||
<option value="status">По статусу</option>
|
||||
<option value="task_type">По типу</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Период</label>
|
||||
<Select
|
||||
id="time-range-select"
|
||||
value={m.filters.time_range}
|
||||
onchange={handleTimeRangeChange}
|
||||
class="w-32"
|
||||
>
|
||||
<option value="all">Всё время</option>
|
||||
<option value="1h">Час</option>
|
||||
<option value="24h">24 часа</option>
|
||||
<option value="7d">7 дней</option>
|
||||
<option value="30d">30 дней</option>
|
||||
</Select>
|
||||
</div>
|
||||
<span class="text-xs text-text-muted ml-auto">{m.filteredTasks.length} задач(-и)</span>
|
||||
{#if m.hasActiveFilters}
|
||||
<Button variant="ghost" onclick={() => m.clearFilters()}>Сбросить фильтры</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Task list (basic for US1) -->
|
||||
<div class="bg-surface-card border border-border rounded-lg p-4">
|
||||
{#if m.screenState === 'loading'}
|
||||
<div class="space-y-3">
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
<div class="bg-surface-muted rounded h-12 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if m.isEmpty}
|
||||
<EmptyState
|
||||
title="Нет активных задач"
|
||||
description="В системе нет задач для отображения."
|
||||
/>
|
||||
{:else if m.isFilteredEmpty}
|
||||
<EmptyState
|
||||
title="Нет задач по выбранным фильтрам"
|
||||
description="Попробуйте изменить условия фильтрации."
|
||||
actionLabel="Сбросить фильтры"
|
||||
onAction={() => m.clearFilters()}
|
||||
/>
|
||||
{:else}
|
||||
<ReportsList
|
||||
reports={m.filteredTasks}
|
||||
selectedReportId={m.selectedTaskId}
|
||||
onselect={(e) => m.selectTask(e.report.task_id || e.report.report_id)}
|
||||
/>
|
||||
{#if m.totalPages > 1}
|
||||
<div class="mt-4">
|
||||
<Pagination
|
||||
bind:page={pageZero}
|
||||
pageSize={m.pageSize}
|
||||
total={m.totalItems}
|
||||
showingText={'Показано с {start} по {end} из {total} задач'}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion TaskCenterReportsPage -->
|
||||
|
||||
45
frontend/src/routes/reports/+page.ts
Normal file
45
frontend/src/routes/reports/+page.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// #region TaskCenterPageLoad [C:2] [TYPE Module] [SEMANTICS load, page, reports, task-status-center]
|
||||
// @BRIEF Load function for /reports — parses initial filters from URL query params.
|
||||
// @POST Returns initialFilters for TaskCenterModel.
|
||||
import type { TaskCenterFilter, TaskType, ReportStatus } from '$types/reports';
|
||||
|
||||
export interface PageData {
|
||||
initialFilters: Partial<TaskCenterFilter>;
|
||||
}
|
||||
|
||||
export function load({ url }: { url: URL }): PageData {
|
||||
const initialFilters: Partial<TaskCenterFilter> = {};
|
||||
|
||||
const typeParam = url.searchParams.get('task_types');
|
||||
if (typeParam) {
|
||||
initialFilters.task_types = typeParam.split(',').filter(Boolean) as TaskType[];
|
||||
}
|
||||
|
||||
const statusParam = url.searchParams.get('statuses');
|
||||
if (statusParam) {
|
||||
initialFilters.statuses = statusParam.split(',').filter(Boolean) as ReportStatus[];
|
||||
}
|
||||
|
||||
const search = url.searchParams.get('search');
|
||||
if (search) {
|
||||
initialFilters.search = search;
|
||||
}
|
||||
|
||||
const range = url.searchParams.get('range');
|
||||
if (range && ['1h', '24h', '7d', '30d'].includes(range)) {
|
||||
initialFilters.time_range = range as TaskCenterFilter['time_range'];
|
||||
}
|
||||
|
||||
const sort = url.searchParams.get('sort');
|
||||
if (sort && ['updated_at', 'created_at', 'status', 'task_type'].includes(sort)) {
|
||||
initialFilters.sort_by = sort as TaskCenterFilter['sort_by'];
|
||||
}
|
||||
|
||||
const order = url.searchParams.get('order');
|
||||
if (order && ['asc', 'desc'].includes(order)) {
|
||||
initialFilters.sort_order = order as TaskCenterFilter['sort_order'];
|
||||
}
|
||||
|
||||
return { initialFilters };
|
||||
}
|
||||
// #endregion TaskCenterPageLoad
|
||||
131
frontend/src/types/reports.ts
Normal file
131
frontend/src/types/reports.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// #region ReportsTypes [C:2] [TYPE Module] [SEMANTICS types,dto,reports,task-status-center]
|
||||
// @BRIEF TypeScript DTOs matching backend Pydantic schemas for the Task Status Center.
|
||||
// @DATA_CONTRACT Input: TypeScript interfaces → Output: typed API responses
|
||||
// @RELATION DEPENDS_ON -> [ReportModels] via @DATA_CONTRACT cross-stack
|
||||
// @RATIONALE Typed API responses prevent field mismatch bugs and enable IDE autocomplete.
|
||||
|
||||
/** Canonical task types matching backend TaskType enum. */
|
||||
export type TaskType =
|
||||
| 'llm_verification'
|
||||
| 'backup'
|
||||
| 'migration'
|
||||
| 'documentation'
|
||||
| 'clean_release'
|
||||
| 'unknown';
|
||||
|
||||
/** Normalized report status matching backend ReportStatus enum. */
|
||||
export type ReportStatus = 'success' | 'failed' | 'in_progress' | 'partial';
|
||||
|
||||
/** UI screen states for the Task Center page. */
|
||||
export type ScreenState = 'loading' | 'ready' | 'empty' | 'disconnected' | 'reconnecting' | 'error';
|
||||
|
||||
/** Error/recovery context for failed tasks. */
|
||||
export interface ErrorContext {
|
||||
code: string | null;
|
||||
message: string;
|
||||
next_actions: string[];
|
||||
}
|
||||
|
||||
/** Single task report — matches backend TaskReport. */
|
||||
export interface TaskReport {
|
||||
report_id: string;
|
||||
task_id: string;
|
||||
task_type: TaskType;
|
||||
status: ReportStatus;
|
||||
started_at: string | null;
|
||||
updated_at: string;
|
||||
summary: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
validation_record?: Record<string, unknown> | null;
|
||||
error_context: ErrorContext | null;
|
||||
source_ref?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Query parameters for report list filtering — matches backend ReportQuery. */
|
||||
export interface ReportQuery {
|
||||
page: number;
|
||||
page_size: number;
|
||||
task_types: TaskType[];
|
||||
statuses: ReportStatus[];
|
||||
time_from?: string | null;
|
||||
time_to?: string | null;
|
||||
search?: string | null;
|
||||
sort_by: 'updated_at' | 'created_at' | 'status' | 'task_type';
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** Paginated collection — matches backend ReportCollection. */
|
||||
export interface ReportCollection {
|
||||
items: TaskReport[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_next: boolean;
|
||||
applied_filters: ReportQuery;
|
||||
}
|
||||
|
||||
/** Detailed view — matches backend ReportDetailView. */
|
||||
export interface ReportDetailView {
|
||||
report: TaskReport;
|
||||
timeline: Record<string, unknown>[];
|
||||
diagnostics: Record<string, unknown> | null;
|
||||
next_actions: string[];
|
||||
}
|
||||
|
||||
/** Status counts for one task type in the summary. */
|
||||
export interface StatusCounts {
|
||||
pending: number;
|
||||
running: number;
|
||||
awaiting_input: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** Per-type summary entry. */
|
||||
export interface TaskTypeSummary {
|
||||
task_type: TaskType;
|
||||
display_label: string;
|
||||
icon_token: string;
|
||||
counts: StatusCounts;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Full summary response — matches backend TaskSummary. */
|
||||
export interface TaskSummary {
|
||||
by_type: TaskTypeSummary[];
|
||||
total_tasks: number;
|
||||
active_tasks: number;
|
||||
}
|
||||
|
||||
/** WebSocket task status event from /ws/task-events. */
|
||||
export interface TaskStatusEvent {
|
||||
type: 'task_status';
|
||||
task_id: string;
|
||||
task: {
|
||||
id: string;
|
||||
plugin_id: string;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
user_id: string | null;
|
||||
params: Record<string, unknown> | null;
|
||||
result: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter state for the Task Center UI (broader than ReportQuery). */
|
||||
export interface TaskCenterFilter {
|
||||
task_types: TaskType[];
|
||||
statuses: ReportStatus[];
|
||||
search: string;
|
||||
time_range: '1h' | '24h' | '7d' | '30d' | 'all';
|
||||
sort_by: 'updated_at' | 'created_at' | 'status' | 'task_type';
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** Reports settings (admin policy for group visibility). */
|
||||
export interface ReportsSettings {
|
||||
disabled_task_types: TaskType[];
|
||||
}
|
||||
|
||||
// #endregion ReportsTypes
|
||||
@@ -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',
|
||||
|
||||
42
run.sh
42
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
|
||||
|
||||
474
specs/034-task-status-center/contracts/modules.md
Normal file
474
specs/034-task-status-center/contracts/modules.md
Normal file
@@ -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<TaskCenterFilter>): Promise<void> { ... }
|
||||
connectWebSocket(): void { ... }
|
||||
disconnectWebSocket(): void { ... }
|
||||
destroy(): void { ... }
|
||||
|
||||
// === ACTIONS ===
|
||||
applyFilter(partial: Partial<TaskCenterFilter>): void { ... }
|
||||
clearFilters(): void { ... }
|
||||
selectTask(taskId: string): void { ... }
|
||||
async refreshSummary(): Promise<void> { ... }
|
||||
toggleTypeVisibility(type: TaskType): void { ... } // localStorage persist
|
||||
|
||||
// === PRIVATE METHODS ===
|
||||
private _onWsMessage(event: MessageEvent): void { ... }
|
||||
private _flushEventBuffer(): void { ... } // 100ms debounce
|
||||
private _scheduleReconnect(): void { ... } // exponential backoff
|
||||
private _syncFiltersToUrl(): void { ... } // replaceState, 300ms debounce
|
||||
private _buildQueryString(): string { ... }
|
||||
}
|
||||
// #endregion TaskCenterModel
|
||||
```
|
||||
|
||||
### 3.2 SummaryPanel [C:4] [TYPE Component]
|
||||
|
||||
```svelte
|
||||
<!-- frontend/src/lib/components/reports/SummaryPanel.svelte (NEW FILE) -->
|
||||
|
||||
<!-- #region SummaryPanel [C:4] [TYPE Component] [SEMANTICS component, summary, dashboard, websocket-reactive, task-status-center] -->
|
||||
<!-- @BRIEF Summary dashboard panel — grid of clickable cards showing task counts by type × status. -->
|
||||
<!-- @LAYER UI Component -->
|
||||
<!-- @UX_STATE idle: cards rendered normally, clickable -->
|
||||
<!-- @UX_STATE loading: skeleton placeholders (4 cards with pulse animation) -->
|
||||
<!-- @UX_STATE empty: all cards show zero counts -->
|
||||
<!-- @UX_STATE disconnected: last known counts visible, opacity reduced, "disconnected" badge -->
|
||||
<!-- @UX_STATE reconnecting: last known counts visible, yellow pulsing indicator -->
|
||||
<!-- @UX_FEEDBACK Count change: number animates (CSS transition). Card click: emits filter event. -->
|
||||
<!-- @UX_RECOVERY Disconnected: auto-reconnect indicator. Click → manual reconnect button. -->
|
||||
<!-- @UX_REACTIVITY Props: summary: TaskSummary | null, screenState: ScreenState. Derived: by_type grid. -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
|
||||
<!-- @PRE summary is non-null when screenState is 'ready' or 'disconnected'. -->
|
||||
<!-- @POST Clicking a card calls onFilterByTypeAndStatus(taskType, status). -->
|
||||
<!-- @RATIONALE Cards are clickable per spec US2: "click on summary card → filter list by that type×status". -->
|
||||
<script lang="ts">
|
||||
import type { TaskSummary, TaskType, ReportStatus, ScreenState } from '$types/reports';
|
||||
import { Card } from '$lib/ui';
|
||||
|
||||
interface Props {
|
||||
summary: TaskSummary | null;
|
||||
screenState: ScreenState;
|
||||
onFilterByTypeAndStatus: (taskType: TaskType, status: ReportStatus) => void;
|
||||
}
|
||||
|
||||
let { summary, screenState, onFilterByTypeAndStatus }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if screenState === 'loading'}
|
||||
<!-- skeleton -->
|
||||
{:else if summary}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3">
|
||||
{#each summary.by_type as typeSummary}
|
||||
{#each STATUS_KEYS as status}
|
||||
<Card padding="sm" onclick={() => onFilterByTypeAndStatus(typeSummary.task_type, status)}>
|
||||
<!-- icon + count + status badge -->
|
||||
</Card>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- #endregion SummaryPanel -->
|
||||
```
|
||||
|
||||
### 3.3 TaskCenterReportsPage [C:4] [TYPE Page]
|
||||
|
||||
```svelte
|
||||
<!-- frontend/src/routes/reports/+page.svelte (REWRITE from existing) -->
|
||||
|
||||
<!-- #region TaskCenterReportsPage [C:4] [TYPE Page] [SEMANTICS page, reports, task-center, layout, task-status-center] -->
|
||||
<!-- @BRIEF Task Status Center page — redesign of /reports with summary dashboard, real-time updates, and enhanced filtering. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @UX_STATE loading: full-page skeleton -->
|
||||
<!-- @UX_STATE empty: "No tasks" empty state with navigation hints -->
|
||||
<!-- @UX_STATE ready: summary panel + filter bar + task list -->
|
||||
<!-- @UX_STATE disconnected: data visible with stale indicator + reconnect banner -->
|
||||
<!-- @UX_STATE error: error banner + retry button -->
|
||||
<!-- @UX_REACTIVITY Model: TaskCenterModel instance. Page is thin render layer. -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
|
||||
<!-- @RELATION BINDS_TO -> [SummaryPanel] -->
|
||||
<!-- @RELATION BINDS_TO -> [FilterBar] -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskList] -->
|
||||
<!-- @RELATION CALLS -> [TaskDrawer] -->
|
||||
<!-- @PRE User is authenticated (enforced by root +layout.svelte). -->
|
||||
<!-- @POST Page renders summary, filters, and task list. WebSocket connects automatically. -->
|
||||
<!-- @SIDE_EFFECT Initializes TaskCenterModel on mount. Connects WebSocket. Destroys on unmount. -->
|
||||
<!-- @RATIONALE Thin render layer per ADR-0006 model-first pattern — all state and logic lives in TaskCenterModel. -->
|
||||
<script lang="ts">
|
||||
import { TaskCenterModel } from '$lib/models/TaskCenterModel.svelte.ts';
|
||||
import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte';
|
||||
import FilterBar from '$lib/components/reports/FilterBar.svelte';
|
||||
import TaskList from '$lib/components/reports/TaskList.svelte';
|
||||
import { PageHeader, Button } from '$lib/ui';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const m = new TaskCenterModel();
|
||||
|
||||
onMount(() => {
|
||||
m.loadInitialData(data.initialFilters);
|
||||
});
|
||||
|
||||
// $effect for cleanup when component unmounts
|
||||
$effect(() => {
|
||||
return () => m.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader title="Центр статусов">
|
||||
<Button variant="ghost" onclick={() => m.refreshSummary()}>Обновить</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if m.screenState === 'disconnected'}
|
||||
<div class="bg-warning-light border border-warning-ring text-warning p-3 rounded-lg mb-4">
|
||||
Соединение потеряно. Попытка переподключения...
|
||||
<Button variant="secondary" onclick={() => m.connectWebSocket()}>Подключиться сейчас</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SummaryPanel
|
||||
summary={m.summary}
|
||||
screenState={m.screenState}
|
||||
onFilterByTypeAndStatus={(type, status) => m.applyFilter({ task_types: [type], statuses: [status] })}
|
||||
/>
|
||||
|
||||
<FilterBar filters={m.filters} onApplyFilter={m.applyFilter} onClearFilters={m.clearFilters} />
|
||||
|
||||
<TaskList
|
||||
tasks={m.filteredTasks}
|
||||
screenState={m.screenState}
|
||||
selectedTaskId={m.selectedTaskId}
|
||||
page={m.page}
|
||||
onSelectTask={m.selectTask}
|
||||
onPageChange={(p) => { m.page = p; }}
|
||||
/>
|
||||
|
||||
<!-- #endregion TaskCenterReportsPage -->
|
||||
```
|
||||
|
||||
### 3.4 ReportsApiClient [C:3] [TYPE Module]
|
||||
|
||||
```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 <T>).
|
||||
|
||||
// EXISTING — add types
|
||||
import type { TaskReport, ReportCollection, ReportDetailView, ReportQuery } from '$types/reports';
|
||||
|
||||
// NEW
|
||||
import type { TaskSummary } from '$types/reports';
|
||||
|
||||
/** Fetch aggregated task summary. */
|
||||
export async function getReportsSummary(): Promise<TaskSummary> {
|
||||
const response = await api.fetchApi('/reports/summary');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// EXISTING — enhance with types
|
||||
export async function getReports(options: Partial<ReportQuery>): Promise<ReportCollection> {
|
||||
const query = buildReportQueryString(options);
|
||||
const response = await api.fetchApi(`/reports${query}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getReportDetail(reportId: string): Promise<ReportDetailView> {
|
||||
const response = await api.fetchApi(`/reports/${reportId}`);
|
||||
return response.json();
|
||||
}
|
||||
// #endregion ReportsApiClient
|
||||
```
|
||||
|
||||
### 3.5 FilterBar [C:3] [TYPE Component]
|
||||
|
||||
```svelte
|
||||
<!-- frontend/src/lib/components/reports/FilterBar.svelte (NEW FILE — replaces inline filters in +page.svelte) -->
|
||||
|
||||
<!-- #region FilterBar [C:3] [TYPE Component] [SEMANTICS component, filter, search, multi-select, task-status-center] -->
|
||||
<!-- @BRIEF Horizontal filter bar: task type multi-select, status multi-select, text search, time range, sort controls. -->
|
||||
<!-- @LAYER UI Component -->
|
||||
<!-- @UX_STATE default: all filters empty, showing all tasks -->
|
||||
<!-- @UX_STATE filtered: active filters visible with "clear" button -->
|
||||
<!-- @UX_FEEDBACK Filter change: immediate visual indicator on active filter badges -->
|
||||
<!-- @UX_REACTIVITY Props: filters: TaskCenterFilter, onApplyFilter(), onClearFilters(). Pure presentation. -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
|
||||
<!-- @PRE filters is a valid TaskCenterFilter object. -->
|
||||
<!-- @POST Calls onApplyFilter with partial filter updates. -->
|
||||
<script lang="ts">
|
||||
import { Button, Select, Input } from '$lib/ui';
|
||||
import type { TaskCenterFilter, TaskType, ReportStatus } from '$types/reports';
|
||||
|
||||
// ... filter UI: multi-select dropdowns, search input, time range select, sort controls
|
||||
</script>
|
||||
<!-- #endregion FilterBar -->
|
||||
```
|
||||
|
||||
### 3.6 TaskList [C:4] [TYPE Component]
|
||||
|
||||
```svelte
|
||||
<!-- frontend/src/lib/components/reports/TaskList.svelte (MODIFY existing — enhance with pagination, sort, active-first) -->
|
||||
|
||||
<!-- #region TaskList [C:4] [TYPE Component] [SEMANTICS component, list, task, pagination, sort, task-status-center] -->
|
||||
<!-- @BRIEF Paginated task list with active-tasks-first sorting, status badges, and click-to-drawer. -->
|
||||
<!-- @LAYER UI Component -->
|
||||
<!-- @UX_STATE loading: skeleton rows -->
|
||||
<!-- @UX_STATE empty: "No tasks" empty state with CTA buttons (inbox icon) -->
|
||||
<!-- @UX_STATE filtered_empty: "No tasks matching filters" + clear filters button -->
|
||||
<!-- @UX_STATE ready: task rows rendered -->
|
||||
<!-- @UX_FEEDBACK Row click: opens TaskDrawer. Status change: animated status badge transition. -->
|
||||
<!-- @UX_RECOVERY Empty: "Clear filters" button. -->
|
||||
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
|
||||
<!-- @RELATION CALLS -> [TaskDrawer] -->
|
||||
<!-- @PRE tasks is an array of TaskReport (may be empty). -->
|
||||
<!-- @POST Clicking a task calls onSelectTask(taskId) which opens TaskDrawer. -->
|
||||
<!-- #endregion TaskList -->
|
||||
```
|
||||
|
||||
### 3.7 ReportsTypeProfiles [C:2] [TYPE Module]
|
||||
|
||||
```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<string, TypeProfile> = {
|
||||
llm_verification: { key: 'llm_verification', label: 'LLM Verification', variant: 'info', icon: 'sparkles', fallback: false },
|
||||
backup: { key: 'backup', label: 'Backup', variant: 'default', icon: 'archive', fallback: false },
|
||||
migration: { key: 'migration', label: 'Migration', variant: 'default', icon: 'shuffle', fallback: false },
|
||||
documentation: { key: 'documentation', label: 'Documentation', variant: 'default', icon: 'file-text', fallback: false },
|
||||
clean_release: { key: 'clean_release', label: 'Clean Release', variant: 'success', icon: 'shield-check', fallback: false }, // ← NEW
|
||||
};
|
||||
return profiles[taskType] ?? profiles.unknown;
|
||||
}
|
||||
// #endregion ReportsTypeProfiles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Contract Traceability Matrix
|
||||
|
||||
| Spec FR | Contract(s) | Type | Complexity |
|
||||
|---------|-------------|------|:----------:|
|
||||
| TSC-FR-001 | `SummaryPanel`, `Reports.SummaryRouter`, `Reports.SummaryService` | Component, API, Service | C4, C4, C4 |
|
||||
| TSC-FR-002 | `TaskCenterModel._onWsMessage`, `SummaryPanel` | Model, Component | C4, C4 |
|
||||
| TSC-FR-003 | `TaskList`, `Reports.ListEnhanced` | Component, API | C4, C4 |
|
||||
| TSC-FR-004 | `FilterBar`, `TaskCenterModel.applyFilter` | Component, Model | C3, C4 |
|
||||
| TSC-FR-005 | `TaskList` (row template), `TaskReport` | Component, Model | C4, C3 |
|
||||
| TSC-FR-006 | `TaskCenterModel.selectTask`, `TaskDrawer` | Model, Component | C4, existing |
|
||||
| TSC-FR-007 | `TaskList` (awaiting_input indicator) | Component | C4 |
|
||||
| TSC-FR-008 | `TaskCenterModel` (reconnect logic), `TaskCenterReportsPage` | Model, Page | C4, C4 |
|
||||
| TSC-FR-009 | `Reports.RbacTaskFilter`, `Reports.SummaryService` | Function, Service | C3, C4 |
|
||||
| TSC-FR-010 | `TaskCenterModel.filteredTasks` ($derived) | Model | C4 |
|
||||
|
||||
#endregion ModuleContracts
|
||||
89
specs/034-task-status-center/contracts/ux/alternatives.md
Normal file
89
specs/034-task-status-center/contracts/ux/alternatives.md
Normal file
@@ -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
|
||||
122
specs/034-task-status-center/contracts/ux/api-ux.md
Normal file
122
specs/034-task-status-center/contracts/ux/api-ux.md
Normal file
@@ -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 <JWT>
|
||||
```
|
||||
|
||||
**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 <JWT>
|
||||
```
|
||||
|
||||
**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 <JWT>
|
||||
```
|
||||
|
||||
**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 <JWT>
|
||||
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=<JWT>
|
||||
```
|
||||
|
||||
**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
|
||||
25
specs/034-task-status-center/contracts/ux/decisions.md
Normal file
25
specs/034-task-status-center/contracts/ux/decisions.md
Normal file
@@ -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
|
||||
98
specs/034-task-status-center/contracts/ux/design-tokens.md
Normal file
98
specs/034-task-status-center/contracts/ux/design-tokens.md
Normal file
@@ -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 `<PageHeader>` 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 `<Icon>` 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 `<Button variant="ghost">` atom) |
|
||||
|
||||
### Pagination
|
||||
|
||||
| Element | Classes |
|
||||
|---------|---------|
|
||||
| Container | `flex items-center justify-between mt-4` |
|
||||
| Page text | `text-text-muted text-sm` |
|
||||
| Buttons | (via `<Button variant="secondary">` atom) |
|
||||
|
||||
### Settings Gear (⚙)
|
||||
|
||||
| Element | Classes |
|
||||
|---------|---------|
|
||||
| Gear button | `text-text-muted hover:text-text cursor-pointer` (via `<Icon name="settings">` atom) |
|
||||
| Dropdown | `bg-surface-card border border-border rounded-lg shadow-lg p-2 absolute z-10` |
|
||||
| Checkbox label | `text-text text-sm flex items-center gap-2` |
|
||||
|
||||
## Forbidden (raw Tailwind colors)
|
||||
|
||||
Never use: `bg-blue-600`, `text-gray-900`, `border-red-300`, etc. All colors via semantic tokens above.
|
||||
|
||||
#endregion UxDesignTokens
|
||||
79
specs/034-task-status-center/contracts/ux/screen-models.md
Normal file
79
specs/034-task-status-center/contracts/ux/screen-models.md
Normal file
@@ -0,0 +1,79 @@
|
||||
#region UxScreenModels [C:3] [TYPE ADR] [SEMANTICS ux,screen-models,task-status-center]
|
||||
@defgroup Ux Screen Model inventory for Task Status Center.
|
||||
|
||||
## Model: TaskCenterModel
|
||||
|
||||
- **File**: `frontend/src/lib/models/TaskCenterModel.svelte.ts`
|
||||
- **Type**: `[TYPE Model]` C4
|
||||
- **Bound to**: `/reports` page (`TaskCenterReportsPage`)
|
||||
- **Complexity**: C4 — stateful, WebSocket lifecycle, side effects
|
||||
|
||||
### Atoms ($state)
|
||||
|
||||
| Atom | Type | Default | Purpose |
|
||||
|------|------|---------|---------|
|
||||
| `tasks` | `TaskReport[]` | `[]` | Full task list from backend |
|
||||
| `summary` | `TaskSummary \| null` | `null` | Aggregated counts by type×status |
|
||||
| `filters` | `TaskCenterFilter` | defaults | Current filter state |
|
||||
| `screenState` | `ScreenState` | `'loading'` | FSM state: loading/ready/empty/disconnected/reconnecting/error |
|
||||
| `wsConnected` | `boolean` | `false` | WebSocket connection status |
|
||||
| `selectedTaskId` | `string \| null` | `null` | Currently selected task for drawer |
|
||||
| `page` | `number` | `1` | Current page |
|
||||
| `pageSize` | `number` | `20` | Items per page |
|
||||
| `disabledTypes` | `TaskType[]` | `[]` | Admin-disabled types (from backend) |
|
||||
| `visibleTypes` | `TaskType[]` | all | Operator preference (from localStorage) |
|
||||
| `lastUpdated` | `Date \| null` | `null` | Timestamp of last data update (for stale indicator) |
|
||||
|
||||
### Derived ($derived)
|
||||
|
||||
| Derived | Type | Computation |
|
||||
|---------|------|-------------|
|
||||
| `filteredTasks` | `TaskReport[]` | tasks → filter by types/statuses/search/time_range → sort (active first, then sort_by) |
|
||||
| `visibleSummary` | `TaskSummary \| null` | summary filtered by disabledTypes AND visibleTypes |
|
||||
| `isEmpty` | `boolean` | tasks.length === 0 && screenState === 'ready' |
|
||||
| `isFilteredEmpty` | `boolean` | filteredTasks.length === 0 && filters active |
|
||||
| `hasActiveFilters` | `boolean` | any filter non-default |
|
||||
|
||||
### Actions
|
||||
|
||||
| Action | Signature | Side Effects |
|
||||
|--------|-----------|-------------|
|
||||
| `loadInitialData` | `(initialFilters?: Partial<TaskCenterFilter>) => Promise<void>` | REST: summary + list + settings; WS: connect |
|
||||
| `applyFilter` | `(partial: Partial<TaskCenterFilter>) => void` | URL: replaceState (debounced 300ms) |
|
||||
| `clearFilters` | `() => void` | URL: replaceState |
|
||||
| `selectTask` | `(taskId: string) => void` | Store: openDrawerForTask |
|
||||
| `refreshSummary` | `() => Promise<void>` | REST: summary + list |
|
||||
| `connectWebSocket` | `() => void` | WS: connect /ws/task-events |
|
||||
| `disconnectWebSocket` | `() => void` | WS: close |
|
||||
| `destroy` | `() => void` | WS: disconnect, timers: clear |
|
||||
| `toggleTypeVisibility` | `(type: TaskType) => void` | localStorage: update visibleTypes |
|
||||
|
||||
### Invariants
|
||||
|
||||
1. Active tasks (PENDING, RUNNING, AWAITING_INPUT) always sorted before completed in `filteredTasks`
|
||||
2. `visibleSummary` excludes types in `disabledTypes` (admin priority) and not in `visibleTypes` (operator preference)
|
||||
3. WebSocket lifecycle: connect on `loadInitialData`, disconnect on `destroy`
|
||||
4. DECOMPOSITION GATE: 400 lines, 40 methods (ADR-0010)
|
||||
|
||||
### Private
|
||||
|
||||
- `_ws: WebSocket | null`
|
||||
- `_eventBuffer: TaskStatusEvent[]`
|
||||
- `_flushTimeout: ReturnType<typeof setTimeout> | null`
|
||||
- `_reconnectAttempt: number`
|
||||
- `_reconnectTimer: ReturnType<typeof setTimeout> | null`
|
||||
- `_urlSyncTimeout: ReturnType<typeof setTimeout> | null`
|
||||
|
||||
### FSM
|
||||
|
||||
```
|
||||
loading ──(data loaded)──► ready
|
||||
loading ──(data empty)───► empty
|
||||
loading ──(fetch fail)────► error
|
||||
ready ──(ws close)────────► reconnecting ──(2s)──► disconnected
|
||||
reconnecting ──(ws open)──► ready
|
||||
disconnected ──(ws open)──► ready
|
||||
ready ──(filter applied, no results)──► filtered_empty (list only, summary stays)
|
||||
```
|
||||
|
||||
#endregion UxScreenModels
|
||||
90
specs/034-task-status-center/contracts/ux/task-center-ux.md
Normal file
90
specs/034-task-status-center/contracts/ux/task-center-ux.md
Normal file
@@ -0,0 +1,90 @@
|
||||
#region TaskCenterUx [C:4] [TYPE ADR] [SEMANTICS ux,task-center,reports,task-status-center]
|
||||
@defgroup Ux UX contract for Task Status Center screen.
|
||||
|
||||
## FSM
|
||||
|
||||
```
|
||||
loading ──(summary+list loaded)──► ready
|
||||
loading ──(total_tasks=0)────────► empty
|
||||
loading ──(fetch 503)─────────────► error
|
||||
ready ──(ws close)────────────────► reconnecting ──(2s)──► disconnected
|
||||
reconnecting ──(ws open)──────────► ready
|
||||
disconnected ──(ws open)──────────► ready
|
||||
ready ──(filter, no results)──────► filtered_empty (list only)
|
||||
filtered_empty ──(clear filters)──► ready
|
||||
error ──(retry/manual)────────────► loading
|
||||
```
|
||||
|
||||
## State Mappings
|
||||
|
||||
| @UX_STATE | Visual (Left 1/3) | Visual (Right 2/3) | ARIA | User Can |
|
||||
|-----------|-------------------|---------------------|------|----------|
|
||||
| `loading` | 3-4 skeleton cards (pulse) | 5-8 skeleton rows (pulse) | `aria-busy="true"` | Wait |
|
||||
| `ready` | Type groups with clickable count badges | Paginated task list, active first | — | Click summary card, filter, click task, paginate |
|
||||
| `empty` | All types show zero counts | EmptyState: inbox icon + "Нет активных задач" + CTA buttons | — | Click "Запустить перевод/миграцию/бекап" |
|
||||
| `reconnecting` | Last data visible, yellow indicator ◉ | Last data visible | `aria-label="Переподключение"` | Wait, click "Подключиться сейчас" |
|
||||
| `disconnected` | Last data visible, red indicator ○, banner | Last data visible, frozen | `role="alert"` on banner | Click "Подключиться сейчас" |
|
||||
| `error` | Compact error message | Error banner + "Повторить" + "Автоповтор 10с" | `role="alert"` | Click "Повторить" |
|
||||
| `filtered_empty` | Summary visible, active filter highlighted | EmptyState: "Нет {status} {type}" + "Сбросить фильтры" | — | Click "Сбросить фильтры", click different summary card |
|
||||
|
||||
## Feedback
|
||||
|
||||
| Trigger | Feedback | Rationale |
|
||||
|---------|----------|-----------|
|
||||
| Task status change in list | Badge color transition (CSS 300ms) | Smooth, non-jarring |
|
||||
| New task appears | Row slide-in from top (CSS 200ms) | Draws attention to new item |
|
||||
| Task completes (SUCCESS) | Row flash green (1s), summary count +1 | Confirmation of success |
|
||||
| Task fails (FAILED) | Row flash red (1s), destructive left border | Visual urgency |
|
||||
| Summary count change | Number animates (CSS transition) | Subtle, not distracting |
|
||||
| Filter applied | Active filter badges appear in FilterBar | Visible state of filtering |
|
||||
| Search typing | Debounced 300ms, no per-keystroke UI change | Prevents flicker |
|
||||
|
||||
## Recovery
|
||||
|
||||
| From | Action | To |
|
||||
|------|--------|-----|
|
||||
| `disconnected` | Auto-reconnect (1s,2s,4s,8s,max 30s) OR click "Подключиться сейчас" | `reconnecting` → `ready` |
|
||||
| `error` | Click "Повторить" OR auto-retry 10s | `loading` → `ready`/`error` |
|
||||
| `filtered_empty` | Click "Сбросить фильтры" | `ready` (full list) |
|
||||
| `empty` | Click "Запустить перевод/миграцию/бекап" | Navigate to respective page |
|
||||
|
||||
## Reactivity
|
||||
|
||||
- **Model atoms** (`$state`): tasks, summary, filters, screenState, wsConnected, page, visibleTypes, disabledTypes, lastUpdated
|
||||
- **Derived** (`$derived`): filteredTasks (filter+sort), visibleSummary (exclude disabled/hidden), isEmpty, isFilteredEmpty, hasActiveFilters
|
||||
- **Effects** (`$effect`): WebSocket message handler → 100ms debounce buffer → apply to tasks → $derived recompute
|
||||
- **URL sync**: filter changes → `history.replaceState` (debounced 300ms)
|
||||
- **localStorage sync**: visibleTypes changes → `localStorage.setItem('taskCenter.visibleTypes', ...)`
|
||||
|
||||
## Group Visibility Logic
|
||||
|
||||
```
|
||||
visibleSummary = summary.by_type.filter(
|
||||
type => !disabledTypes.includes(type) && visibleTypes.includes(type)
|
||||
)
|
||||
```
|
||||
|
||||
- `disabledTypes`: from `GET /api/settings/reports` (admin policy, priority)
|
||||
- `visibleTypes`: from `localStorage` (operator preference, default: all)
|
||||
- Admin-disabled types: simply hidden from operator (no lock icon, no indication)
|
||||
|
||||
## UX Tests
|
||||
|
||||
| @UX_TEST | Given | When | Then |
|
||||
|----------|-------|------|------|
|
||||
| `summary_loads` | Page opened | Summary API returns data | Cards visible with correct counts |
|
||||
| `summary_empty` | No tasks in system | Summary returns total=0 | Left: zeros, Right: EmptyState with CTA |
|
||||
| `ws_status_update` | Page ready, WS connected | Task status event received | Badge transitions, count updates within 100ms |
|
||||
| `ws_disconnect` | Page ready | WS closes abnormally | Yellow indicator → banner after 2s → data visible |
|
||||
| `ws_reconnect` | Disconnected | WS reconnects | Green indicator, banner hidden, summary re-synced |
|
||||
| `summary_card_click` | Page ready | Click "Migration / Running (3)" | List filters to running migrations, time+search preserved |
|
||||
| `text_search` | Page ready | Type "dashboard" (debounce 300ms) | List filters by text match |
|
||||
| `filter_empty` | Filter: Backup + Running | No running backups | Right: "Нет выполняющихся бекапов" + "Сбросить фильтры" |
|
||||
| `task_click` | Page ready, FAILED task visible | Click task row | Task Drawer opens with logs, scroll to last error |
|
||||
| `awaiting_input` | Task in AWAITING_INPUT | View task row | Yellow badge + "Ответить" button visible |
|
||||
| `group_toggle` | Page ready | Click ⚙, uncheck "Документация" | Documentation group disappears from summary |
|
||||
| `admin_disabled` | Admin disabled "Clean Release" | Operator opens page | Clean Release not visible in summary or ⚙ menu |
|
||||
| `pagination` | 25 tasks, page 1 | Click "Вперёд" | Page 2 loads, URL updates ?page=2 |
|
||||
| `mobile_collapse` | Viewport <768px | Open /reports | Summary collapses to horizontal totals bar, tap to expand |
|
||||
|
||||
#endregion TaskCenterUx
|
||||
296
specs/034-task-status-center/data-model.md
Normal file
296
specs/034-task-status-center/data-model.md
Normal file
@@ -0,0 +1,296 @@
|
||||
#region DataModel [C:4] [TYPE ADR] [SEMANTICS data-model, schema, pydantic, typescript, dto, task-status-center]
|
||||
@BRIEF Data model for the Task Status Center feature — backend Pydantic schemas (new + modified), frontend TypeScript DTOs, and the cross-stack contract bridge.
|
||||
@RATIONALE The data model defines WHAT flows between backend and frontend. Without explicit DTO contracts, type drift between Python and TypeScript is inevitable over long-horizon development.
|
||||
@REJECTED Auto-generating TypeScript from Pydantic was rejected (R7) — the schema set is small enough that manual sync is proportionate and avoids build-step complexity.
|
||||
|
||||
## 1. Entity Relationship Summary
|
||||
|
||||
```
|
||||
TaskRecord (SQLAlchemy, persisted)
|
||||
└── Task (Pydantic, in-memory via TaskManager)
|
||||
└── TaskReport (Pydantic, normalized for UI)
|
||||
├── ReportCollection (paginated list)
|
||||
├── ReportDetailView (single task detail)
|
||||
└── TaskSummary (aggregated counts) ← NEW
|
||||
|
||||
TaskSummary
|
||||
└── TaskTypeSummary[] (one per task_type)
|
||||
└── StatusCounts (pending, running, awaiting_input, success, failed)
|
||||
```
|
||||
|
||||
## 2. New Backend Schemas
|
||||
|
||||
### 2.1 TaskSummary (NEW)
|
||||
|
||||
```python
|
||||
# backend/src/models/report.py (add to existing module)
|
||||
|
||||
# #region TaskSummary [C:3] [TYPE Class] [SEMANTICS summary, aggregation, task, status, count]
|
||||
# @ingroup ReportModels
|
||||
# @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
|
||||
```
|
||||
|
||||
### 2.2 Enhanced ReportQuery (MODIFIED)
|
||||
|
||||
The existing `ReportQuery` already supports `task_types[]`, `statuses[]`, `search`, `time_from`, `time_to`, `sort_by`, `sort_order`. No schema changes needed — the filtering logic in the service method will be enhanced to use these parameters more thoroughly.
|
||||
|
||||
**Change:** `sort_by` validator adds `"created_at"` to allowed values (currently only `updated_at`, `status`, `task_type`).
|
||||
|
||||
### 2.3 Enhanced TaskReport (MODIFIED)
|
||||
|
||||
No new fields needed. The existing `TaskReport` already carries all required fields for the spec:
|
||||
- `report_id` → task identifier in list
|
||||
- `task_type` → filter/summary grouping
|
||||
- `status` → filter/summary grouping + color coding
|
||||
- `started_at` → temporal display
|
||||
- `updated_at` → sort default
|
||||
- `summary` → display text
|
||||
- `error_context` → error display + next actions
|
||||
- `source_ref` → linked resource (dashboard, dataset, config)
|
||||
|
||||
### 2.4 No DB Schema Changes
|
||||
|
||||
No new database tables or columns. The feature works entirely with:
|
||||
- Existing `task_records` table (persisted task metadata)
|
||||
- Existing `task_logs` table (persisted logs for Task Drawer)
|
||||
- In-memory `TaskManager.get_all_tasks()` as the data source
|
||||
|
||||
## 3. New Frontend TypeScript DTOs
|
||||
|
||||
```typescript
|
||||
// frontend/src/types/reports.ts (NEW FILE)
|
||||
|
||||
// #region ReportsTypes [C:3] [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';
|
||||
|
||||
/** Error/recovery context for failed tasks. */
|
||||
export interface ErrorContext {
|
||||
code: string | null;
|
||||
message: string;
|
||||
next_actions: string[];
|
||||
}
|
||||
|
||||
/** Single task report — matches backend TaskReport. */
|
||||
export interface TaskReport {
|
||||
report_id: string;
|
||||
task_id: string;
|
||||
task_type: TaskType;
|
||||
status: ReportStatus;
|
||||
started_at: string | null;
|
||||
updated_at: string;
|
||||
summary: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
validation_record?: Record<string, unknown> | null;
|
||||
error_context: ErrorContext | null;
|
||||
source_ref?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Query parameters for report list filtering — matches backend ReportQuery. */
|
||||
export interface ReportQuery {
|
||||
page: number;
|
||||
page_size: number;
|
||||
task_types: TaskType[];
|
||||
statuses: ReportStatus[];
|
||||
time_from?: string | null;
|
||||
time_to?: string | null;
|
||||
search?: string | null;
|
||||
sort_by: 'updated_at' | 'created_at' | 'status' | 'task_type';
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** Paginated collection — matches backend ReportCollection. */
|
||||
export interface ReportCollection {
|
||||
items: TaskReport[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_next: boolean;
|
||||
applied_filters: ReportQuery;
|
||||
}
|
||||
|
||||
/** Detailed view — matches backend ReportDetailView. */
|
||||
export interface ReportDetailView {
|
||||
report: TaskReport;
|
||||
timeline: Record<string, unknown>[];
|
||||
diagnostics: Record<string, unknown> | null;
|
||||
next_actions: string[];
|
||||
}
|
||||
|
||||
// --- NEW for Task Status Center ---
|
||||
|
||||
/** Status counts for one task type in the summary. */
|
||||
export interface StatusCounts {
|
||||
pending: number;
|
||||
running: number;
|
||||
awaiting_input: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** Per-type summary entry. */
|
||||
export interface TaskTypeSummary {
|
||||
task_type: TaskType;
|
||||
display_label: string;
|
||||
icon_token: string;
|
||||
counts: StatusCounts;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Full summary response — matches backend TaskSummary. */
|
||||
export interface TaskSummary {
|
||||
by_type: TaskTypeSummary[];
|
||||
total_tasks: number;
|
||||
active_tasks: number;
|
||||
}
|
||||
|
||||
/** WebSocket task status event from /ws/task-events. */
|
||||
export interface TaskStatusEvent {
|
||||
type: 'task_status';
|
||||
task_id: string;
|
||||
task: {
|
||||
id: string;
|
||||
plugin_id: string;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
user_id: string | null;
|
||||
params: Record<string, unknown> | null;
|
||||
result: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter state for the Task Center UI (broader than ReportQuery). */
|
||||
export interface TaskCenterFilter {
|
||||
task_types: TaskType[];
|
||||
statuses: ReportStatus[];
|
||||
search: string;
|
||||
time_range: '1h' | '24h' | '7d' | '30d' | 'all';
|
||||
sort_by: 'updated_at' | 'created_at' | 'status' | 'task_type';
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** UI screen states for the Task Center page. */
|
||||
export type ScreenState =
|
||||
| 'loading'
|
||||
| 'ready'
|
||||
| 'empty'
|
||||
| 'error'
|
||||
| 'disconnected'
|
||||
| 'reconnecting';
|
||||
// #endregion ReportsTypes
|
||||
```
|
||||
|
||||
## 4. Cross-Stack Contract Bridge
|
||||
|
||||
```
|
||||
Backend (Pydantic) Frontend (TypeScript)
|
||||
───────────────── ─────────────────────
|
||||
TaskType (str Enum) ←──→ TaskType (union type)
|
||||
ReportStatus (str Enum) ←──→ ReportStatus (union type)
|
||||
TaskReport (BaseModel) ←──→ interface TaskReport
|
||||
ReportQuery (BaseModel) ←──→ interface ReportQuery
|
||||
ReportCollection ←──→ interface ReportCollection
|
||||
ReportDetailView ←──→ interface ReportDetailView
|
||||
TaskSummary (NEW) ←──→ interface TaskSummary
|
||||
StatusCounts (NEW) ←──→ interface StatusCounts
|
||||
TaskTypeSummary (NEW) ←──→ interface TaskTypeSummary
|
||||
```
|
||||
|
||||
Each bridge pair carries `@DATA_CONTRACT` annotation referencing the counterpart:
|
||||
- Backend: `@DATA_CONTRACT Output: TaskSummary → frontend interface TaskSummary`
|
||||
- Frontend: `@DATA_CONTRACT Input: TypeScript interface → Output: typed API response`
|
||||
|
||||
## 5. Data Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BROWSER │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ TaskCenterModel (.svelte.ts) │ │
|
||||
│ │ │ │
|
||||
│ │ loadInitialData() │ │
|
||||
│ │ ├─ GET /api/reports/summary ──────► TaskSummary │ │
|
||||
│ │ └─ GET /api/reports?page=1&... ──► ReportCollection │ │
|
||||
│ │ │ │
|
||||
│ │ connectWebSocket() │ │
|
||||
│ │ └─ ws://host/ws/task-events ────► TaskStatusEvent x N │ │
|
||||
│ │ │ │ │
|
||||
│ │ └─ 100ms debounce buffer │ │
|
||||
│ │ └─ apply to tasks[] ──► $derived summary │ │
|
||||
│ │ │ │
|
||||
│ │ selectTask(taskId) │ │
|
||||
│ │ └─ openDrawerForTask(taskId) ──► TaskDrawer + logs WS │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ +page.svelte (thin render layer ~60 lines) │ │
|
||||
│ │ ├─ SummaryPanel ← m.summary │ │
|
||||
│ │ ├─ FilterBar ← m.filters, m.applyFilter() │ │
|
||||
│ │ └─ TaskList ← m.filteredTasks, m.selectTask() │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND (FastAPI) │
|
||||
│ │
|
||||
│ GET /api/reports/summary ───► ReportsService.get_summary() │
|
||||
│ └─ task_manager.get_all_tasks() │
|
||||
│ └─ filter by RBAC (role-based) │
|
||||
│ └─ normalize → aggregate → TaskSummary │
|
||||
│ │
|
||||
│ GET /api/reports?... ───► ReportsService.list_reports() │
|
||||
│ └─ same flow → filter, sort, paginate → ReportCollection │
|
||||
│ │
|
||||
│ ws://host/ws/task-events ───► EventBus.broadcast_status() │
|
||||
│ └─ already broadcasts on every task status transition │
|
||||
│ └─ NO CODE CHANGES NEEDED │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#endregion DataModel
|
||||
174
specs/034-task-status-center/plan.md
Normal file
174
specs/034-task-status-center/plan.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Implementation Plan: Task Status Center
|
||||
|
||||
**Branch**: `034-task-status-center` | **Date**: 2026-07-02 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/034-task-status-center/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Transform the existing `/reports` page into a real-time Task Status Center — a unified monitoring dashboard for all background tasks in superset-tools. The page gains:
|
||||
|
||||
1. **Summary dashboard** (P1) — aggregated task counts by type × status, updated in real-time via existing WebSocket infrastructure.
|
||||
2. **Enhanced filtering** (P2) — multi-select type/status filters, text search, time range, clickable summary cards that auto-apply filters.
|
||||
3. **Task drill-down** (P3) — click any task to open Task Drawer with logs; hover tooltips with key details.
|
||||
4. **RBAC enforcement** (TSC-FR-009) — admin/analyst/viewer see only authorized tasks.
|
||||
|
||||
**Technical approach**: Reuses existing `/ws/task-events` WebSocket for real-time updates. Adds `GET /api/reports/summary` REST endpoint for initial load. Frontend follows model-first pattern: `TaskCenterModel.svelte.ts` Screen Model isolates all state, the page becomes a thin render layer. Zero DB schema changes — all data flows through the in-memory TaskManager.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.13+ (backend), TypeScript (frontend Svelte 5 runes-only)
|
||||
**Primary Dependencies**: FastAPI, SQLAlchemy, APScheduler (backend); SvelteKit 2.x, Vite 7.x, Tailwind CSS 3.x (frontend)
|
||||
**Storage**: PostgreSQL 16 (no schema changes needed)
|
||||
**Testing**: pytest (backend), vitest (L1 model tests without render + L2 UX tests with @testing-library/svelte)
|
||||
**Target Platform**: Linux server (Docker), modern browsers
|
||||
**Project Type**: web application (FastAPI REST + WebSocket backend, SvelteKit SPA frontend)
|
||||
**Frontend Architecture**: TypeScript-first, model-first (Screen Models via `.svelte.ts`), runes-only (`$state`, `$derived`, `$effect`)
|
||||
**Performance Goals**: SC-001: summary load ≤1s; SC-002: WS event-to-DOM ≤2s; SC-004: 30fps at 100+ tasks
|
||||
**Constraints**: RBAC enforcement (TSC-FR-009), WebSocket reconnect ≤5s (SC-005), no new DB schema
|
||||
**Scale/Scope**: 3 user stories, 10 FRs, ~12 files created/modified
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Evidence |
|
||||
|-----------|:------:|----------|
|
||||
| **I. Semantic Contract First** | ✅ PASS | All C3+ modules have `#region` contracts in `contracts/modules.md`. Backend schemas carry `@DATA_CONTRACT`. Frontend DTOs carry cross-stack `@RELATION`. No naked code. |
|
||||
| **II. Decision Memory** | ✅ PASS | All 10 research decisions in `research.md` carry `@RATIONALE`/`@REJECTED`. ADR-0004, ADR-0005, ADR-0006, ADR-0010, ADR-0011 referenced. |
|
||||
| **III. External Orchestrator** | ✅ PASS | Feature extends existing task infrastructure without modifying Superset. Follows ADR-0003 orchestrator pattern. |
|
||||
| **IV. Module Discipline** | ✅ PASS | New Model ≤300 lines (well below 400-line INV_7). All files placed in canonical directories per ADR-0001. No cyclic imports. |
|
||||
| **V. RBAC Enforcement** | ✅ PASS | TSC-FR-009 enforced via `_filter_tasks_by_rbac()` in service layer. Uses existing `User.roles` and `has_permission("tasks", "READ")`. |
|
||||
| **VI. Svelte 5 Runes Only** | ✅ PASS | `TaskCenterModel` uses `$state`/`$derived`. No legacy Svelte 4 syntax. No `writable` stores created. Model uses `.svelte.ts` extension. |
|
||||
| **VII. Test-Driven for C3+** | ✅ PASS | All C4 contracts will have tests verifying `@PRE`/`@POST`/`@INVARIANT`. Rejected-path regression tests for RBAC filtering. |
|
||||
| **VIII. Attention-Optimized Contracts** | ✅ PASS | All contracts use hierarchical IDs (`Reports.SummaryService`, `TaskCenterModel`). `@SEMANTICS` keywords shared within domain. Contract files ≤150 lines. |
|
||||
| **ATTN_1 (CSA 4×)** | ✅ PASS | First anchor line packs `[C:N] [TYPE] [SEMANTICS]` on one line. |
|
||||
| **ATTN_2 (HCA 128×)** | ✅ PASS | IDs use `Domain.Sub.Name` pattern (2-3 levels). |
|
||||
| **ATTN_3 (DSA Indexer)** | ✅ PASS | Same-domain contracts share primary `@SEMANTICS` keyword (`task-status-center`). |
|
||||
| **ATTN_4 (Sliding Window)** | ✅ PASS | All contracts ≤150 lines. Modules ≤400 lines. |
|
||||
|
||||
**Gate Result**: ✅ ALL PRINCIPLES PASS — no blocking conflicts. Proceed to Phase 0.
|
||||
|
||||
### Re-check After Phase 1 Design
|
||||
|
||||
| Check | Status |
|
||||
|-------|:------:|
|
||||
| `TaskCenterModel` estimated lines: ~250 | ✅ Below 400-line gate |
|
||||
| `contracts/modules.md` estimated lines: 280 | ✅ Acceptable for a C5 ADR |
|
||||
| Cross-stack `@DATA_CONTRACT` bridges present | ✅ 9 bridge pairs documented |
|
||||
| RBAC filtering path rejections documented | ✅ `@REJECTED` in R4 |
|
||||
| WebSocket reuse documented | ✅ R2 in research.md |
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/034-task-status-center/
|
||||
├── spec.md # Feature specification
|
||||
├── ux_reference.md # UX interaction reference
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 — 10 research decisions
|
||||
├── data-model.md # Phase 1 — schemas, DTOs, cross-stack bridge
|
||||
├── quickstart.md # Phase 1 — developer setup & verification
|
||||
├── contracts/
|
||||
│ └── modules.md # Phase 1 — GRACE contracts (backend + frontend)
|
||||
├── checklists/
|
||||
│ └── requirements.md # Spec quality validation
|
||||
└── tasks.md # Phase 2 — task decomposition (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── api/routes/
|
||||
│ │ └── reports.py # MODIFY: add /summary endpoint, RBAC enhancement
|
||||
│ ├── models/
|
||||
│ │ └── report.py # MODIFY: add TaskSummary schemas
|
||||
│ └── services/reports/
|
||||
│ └── report_service.py # MODIFY: add get_summary(), _filter_tasks_by_rbac()
|
||||
└── tests/
|
||||
├── api/routes/
|
||||
│ └── test_reports.py # MODIFY: add summary + RBAC tests
|
||||
└── services/reports/
|
||||
└── test_report_service.py # MODIFY: add RBAC filter tests
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── routes/reports/
|
||||
│ │ ├── +page.svelte # REWRITE: thin render layer
|
||||
│ │ └── +page.ts # NEW: load function (URL query parsing)
|
||||
│ ├── lib/
|
||||
│ │ ├── models/
|
||||
│ │ │ └── TaskCenterModel.svelte.ts # NEW: Screen Model
|
||||
│ │ ├── components/reports/
|
||||
│ │ │ ├── SummaryPanel.svelte # NEW: summary dashboard
|
||||
│ │ │ ├── FilterBar.svelte # NEW: filter controls
|
||||
│ │ │ ├── TaskList.svelte # MODIFY: pagination, sort
|
||||
│ │ │ └── reportTypeProfiles.ts # MODIFY: add clean_release
|
||||
│ │ └── api/
|
||||
│ │ └── reports.ts # MODIFY: add getReportsSummary(), types
|
||||
│ └── types/
|
||||
│ └── reports.ts # NEW: TypeScript DTOs
|
||||
└── tests/
|
||||
└── lib/
|
||||
├── models/
|
||||
│ └── TaskCenterModel.test.ts # NEW: model unit tests
|
||||
└── components/reports/
|
||||
├── SummaryPanel.test.ts # NEW: component UX tests
|
||||
└── FilterBar.test.ts # NEW: component UX tests
|
||||
```
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No violations to justify. All contracts are within their appropriate complexity tiers per the semantic protocol:
|
||||
|
||||
| Contract | Tier | Rationale |
|
||||
|----------|:----:|-----------|
|
||||
| `Reports.SummaryRouter` | C4 | Multi-step: RBAC + load + normalize + aggregate |
|
||||
| `Reports.SummaryService` | C4 | Stateful: accesses TaskManager, applies authorization |
|
||||
| `Reports.ListEnhanced` | C4 | Enhanced from C3 with RBAC row-filtering |
|
||||
| `Reports.RbacTaskFilter` | C3 | Pure function: input tasks → output filtered tasks |
|
||||
| `TaskCenterModel` | C4 | Stateful: 15+ atoms, WebSocket lifecycle, side effects |
|
||||
| `SummaryPanel` | C4 | WebSocket-reactive derived display, UX states |
|
||||
| `FilterBar` | C3 | Multi-select inputs, no side effects beyond model mutation |
|
||||
| `TaskList` | C4 | Pagination, sort, click→drawer, UX states |
|
||||
| `TaskCenterReportsPage` | C4 | Page orchestration: Model init, UX state machine |
|
||||
| `ReportsTypes` | C2 | DTO definitions only |
|
||||
| `ReportsApiClient` | C3 | Typed fetch wrappers |
|
||||
|
||||
## Phase Artifacts
|
||||
|
||||
| Artifact | Status | Description |
|
||||
|----------|:------:|-------------|
|
||||
| `research.md` | ✅ Done | 10 research decisions (R1-R10) |
|
||||
| `data-model.md` | ✅ Done | Pydantic schemas, TypeScript DTOs, cross-stack bridge |
|
||||
| `contracts/modules.md` | ✅ Done | 12 GRACE contracts with complexity anchors |
|
||||
| `quickstart.md` | ✅ Done | Developer setup & verification |
|
||||
| `plan.md` | ✅ Done | This file |
|
||||
|
||||
## Validation Against UX Reference
|
||||
|
||||
| UX Promise | Implementation | Status |
|
||||
|------------|----------------|:------:|
|
||||
| `@UX_STATE: idle` | `TaskCenterModel.screenState = 'ready'` | ✅ |
|
||||
| `@UX_STATE: loading` | Skeleton in `SummaryPanel` + `TaskList` | ✅ |
|
||||
| `@UX_STATE: empty` | Empty state in `TaskList` | ✅ |
|
||||
| `@UX_STATE: disconnected` | Reconnect banner in `TaskCenterReportsPage` | ✅ |
|
||||
| `@UX_STATE: reconnecting` | Yellow indicator in model-driven UI | ✅ |
|
||||
| `@UX_STATE: error` | Error banner + retry button | ✅ |
|
||||
| `@UX_FEEDBACK: status change` | Animated badge via CSS transition | ✅ |
|
||||
| `@UX_FEEDBACK: new task` | Slide-in animation (TaskList) | ✅ |
|
||||
| `@UX_FEEDBACK: task completion` | Row highlight + summary count update | ✅ |
|
||||
| `@UX_RECOVERY: WS disconnect` | Auto-reconnect + manual button | ✅ |
|
||||
| `@UX_RECOVERY: load error` | Retry button | ✅ |
|
||||
| `@UX_RECOVERY: empty search` | Clear filters button | ✅ |
|
||||
| `@UX_REACTIVITY: Model-driven` | TaskCenterModel `$state` + `$derived` | ✅ |
|
||||
| Screen Model presence | `TaskCenterModel.svelte.ts` with `@STATE`/`@ACTION`/`@INVARIANT` | ✅ |
|
||||
|
||||
## Next Steps
|
||||
|
||||
Run `/speckit.tasks` to generate the task decomposition (`tasks.md`).
|
||||
|
||||
#endregion PlanDoc
|
||||
133
specs/034-task-status-center/quickstart.md
Normal file
133
specs/034-task-status-center/quickstart.md
Normal file
@@ -0,0 +1,133 @@
|
||||
#region Quickstart [C:2] [TYPE ADR] [SEMANTICS quickstart, development, setup, task-status-center]
|
||||
@BRIEF Developer quickstart for the Task Status Center feature — setup, verification commands, and key file locations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker + Docker Compose (for full stack)
|
||||
- Python 3.13+ with venv (for backend-only development)
|
||||
- Node.js 22+ (for frontend-only development)
|
||||
- Git branch: `034-task-status-center`
|
||||
|
||||
## Quick Start (Docker)
|
||||
|
||||
```bash
|
||||
# 1. Switch to feature branch
|
||||
git checkout 034-task-status-center
|
||||
|
||||
# 2. Start full stack
|
||||
docker compose up -d
|
||||
|
||||
# 3. Frontend at http://localhost:5173
|
||||
# 4. Backend API at http://localhost:8000
|
||||
# 5. WebSocket at ws://localhost:8000/ws/task-events?token=...
|
||||
```
|
||||
|
||||
## Development Setup (without Docker)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python -m uvicorn src.app:app --reload --port 8000
|
||||
|
||||
# Frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Verification Gates
|
||||
|
||||
```bash
|
||||
# Backend: run all tests
|
||||
cd backend && source .venv/bin/activate && python -m pytest -v
|
||||
|
||||
# Backend: run reports-specific tests
|
||||
python -m pytest tests/api/routes/test_reports.py -v
|
||||
python -m pytest tests/services/reports/ -v
|
||||
|
||||
# Backend: lint
|
||||
python -m ruff check .
|
||||
|
||||
# Frontend: run all tests
|
||||
cd frontend && npm run test
|
||||
|
||||
# Frontend: run reports-specific tests
|
||||
npm run test -- src/lib/components/reports/
|
||||
|
||||
# Frontend: lint
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
### Backend (to create/modify)
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|:------:|---------|
|
||||
| `backend/src/api/routes/reports.py` | MODIFY | Add `/summary` endpoint; enhance `list_reports` with RBAC |
|
||||
| `backend/src/services/reports/report_service.py` | MODIFY | Add `get_summary()`, `_filter_tasks_by_rbac()` |
|
||||
| `backend/src/models/report.py` | MODIFY | Add `TaskSummary`, `StatusCounts`, `TaskTypeSummary` schemas |
|
||||
| `backend/tests/api/routes/test_reports.py` | MODIFY | Add summary endpoint tests |
|
||||
| `backend/tests/services/reports/test_report_service.py` | MODIFY | Add RBAC filtering tests |
|
||||
|
||||
### Frontend (to create/modify)
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|:------:|---------|
|
||||
| `frontend/src/lib/models/TaskCenterModel.svelte.ts` | **NEW** | Screen Model — state, WebSocket, filtering, actions |
|
||||
| `frontend/src/types/reports.ts` | **NEW** | TypeScript DTOs matching backend schemas |
|
||||
| `frontend/src/lib/api/reports.ts` | MODIFY | Add `getReportsSummary()`, add types |
|
||||
| `frontend/src/routes/reports/+page.svelte` | **REWRITE** | Thin render layer with summary + filters + list |
|
||||
| `frontend/src/routes/reports/+page.ts` | **NEW** | Load function for URL query param parsing |
|
||||
| `frontend/src/lib/components/reports/SummaryPanel.svelte` | **NEW** | Summary dashboard grid component |
|
||||
| `frontend/src/lib/components/reports/FilterBar.svelte` | **NEW** | Filter bar component |
|
||||
| `frontend/src/lib/components/reports/TaskList.svelte` | MODIFY | Add pagination, active-first sort |
|
||||
| `frontend/src/lib/components/reports/reportTypeProfiles.ts` | MODIFY | Add `clean_release` profile |
|
||||
| `frontend/tests/lib/components/reports/SummaryPanel.test.ts` | **NEW** | Component UX tests |
|
||||
| `frontend/tests/lib/components/reports/FilterBar.test.ts` | **NEW** | Component UX tests |
|
||||
| `frontend/tests/lib/models/TaskCenterModel.test.ts` | **NEW** | Model unit tests |
|
||||
|
||||
## Manual Testing Scenarios
|
||||
|
||||
### 1. Summary Dashboard (US1)
|
||||
1. Create tasks of different types (migration, backup, validation) via the API/UI.
|
||||
2. Open `/reports` — verify summary cards show correct counts.
|
||||
3. Complete a running task — verify count updates via WebSocket within 2 seconds.
|
||||
|
||||
### 2. Filtering (US2)
|
||||
1. Click a summary card — verify task list filters to matching type+status.
|
||||
2. Use multi-select dropdowns — verify combination filtering.
|
||||
3. Type in search box — verify text filtering.
|
||||
4. Select time range — verify temporal filtering.
|
||||
|
||||
### 3. Task Drill-Down (US3)
|
||||
1. Click a FAILED task — verify Task Drawer opens with logs.
|
||||
2. Click a RUNNING task — verify live log streaming in Drawer.
|
||||
3. Hover over a task row — verify tooltip with details.
|
||||
|
||||
### 4. RBAC (TSC-FR-009)
|
||||
1. Login as viewer — verify only own tasks visible.
|
||||
2. Login as analyst — verify own + system tasks visible.
|
||||
3. Login as admin — verify all tasks visible.
|
||||
|
||||
### 5. WebSocket Recovery
|
||||
1. Open `/reports` with WebSocket connected.
|
||||
2. Kill backend (`docker compose stop backend`).
|
||||
3. Verify: yellow reconnecting indicator → red disconnected indicator.
|
||||
4. Restart backend — verify auto-reconnect within 5 seconds (SC-005).
|
||||
|
||||
## Test Data
|
||||
|
||||
```python
|
||||
# Create test tasks via API:
|
||||
POST /api/tasks
|
||||
{
|
||||
"plugin_id": "superset-migration",
|
||||
"action": "migrate_dashboard",
|
||||
"params": {"source_env": "prod", "target_env": "staging", "dashboard_id": 42}
|
||||
}
|
||||
```
|
||||
|
||||
#endregion Quickstart
|
||||
252
specs/034-task-status-center/research.md
Normal file
252
specs/034-task-status-center/research.md
Normal file
@@ -0,0 +1,252 @@
|
||||
#region ResearchDoc [C:4] [TYPE ADR] [SEMANTICS research, architecture, decision, task-status-center, reports]
|
||||
@BRIEF Phase 0 research — resolves all material unknowns for the Task Status Center feature: API design, WebSocket integration, Screen Model decomposition, RBAC row-level filtering, debounce strategy, type sync, and ADR continuity.
|
||||
@RATIONALE Every architectural unknown must be resolved before Phase 1 contract generation. Without explicit decisions, agents default to their pre-trained heuristics, which may conflict with superset-tools conventions.
|
||||
@REJECTED Deferring decisions to Phase 1 was rejected — contracts need stable targets.
|
||||
|
||||
## R1: Summary API Design
|
||||
|
||||
**Decision:** New endpoint `GET /api/reports/summary` returning `TaskSummary` — aggregated counts by `(task_type × status)` with an optional `user_id` filter applied server-side per RBAC.
|
||||
|
||||
**Rationale:**
|
||||
- The existing `list_reports` endpoint loads all tasks and computes everything in-memory. Adding summary computation there would make the endpoint do double duty: paginated list AND aggregation — two different response shapes, two different caching strategies.
|
||||
- A separate endpoint is RESTful — `summary` is a sub-resource of `reports`.
|
||||
- Allows frontend to fetch summary independently (lightweight, cacheable) without pulling the full paginated list.
|
||||
|
||||
**Rejected:**
|
||||
- **Combining summary into list response header** — violates separation of concerns, bloats the list response, and forces every paginated fetch to recompute summary.
|
||||
- **WebSocket-only summary** — initial page load needs REST reliability. WebSocket is for live updates AFTER initial load. REST gives the guaranteed-first-paint contract (SC-001: ≤1 second).
|
||||
|
||||
**Impact:** New backend route, new Pydantic schema `TaskSummary`, new frontend TypeScript DTO, new API client function `getReportsSummary()`.
|
||||
|
||||
---
|
||||
|
||||
## R2: WebSocket Integration Pattern
|
||||
|
||||
**Decision:** Reuse existing `/ws/task-events` WebSocket endpoint. Subscribe in the frontend `TaskCenterModel`, buffer incoming `{type: "task_status", task_id, task}` events in a 100ms debounce window, then apply incremental updates to the in-memory task list and let `$derived` recompute the summary.
|
||||
|
||||
**Rationale:**
|
||||
- The `EventBus.broadcast_status()` already fans out to global subscribers via `_task_event_subscribers`. Every task status transition (PENDING→RUNNING, RUNNING→SUCCESS, RUNNING→FAILED) is already broadcast to `/ws/task-events` with no code changes needed.
|
||||
- Creating a custom WebSocket endpoint for reports would duplicate the same data stream. Maintenance burden of two parallel streams is unjustified.
|
||||
- The event format (`{type: "task_status", task_id, task}`) carries the full `task` dict — enough to determine `plugin_id` (for task type) and `status` (for report status) without an extra API call.
|
||||
|
||||
**Rejected:**
|
||||
- **Polling with `setInterval`** — was the old pattern, rejected because spec calls for real-time updates (SC-002: ≤2 seconds WebSocket-to-DOM latency). Polling introduces unnecessary load and lag.
|
||||
- **Server-Sent Events (SSE)** — rejected because WebSocket infrastructure already exists in the codebase. Adding SSE would create a parallel real-time mechanism.
|
||||
- **Per-task WebSocket subscription (`/ws/logs/{task_id}`)** — rejected because reports page needs ALL task status changes, not just one task.
|
||||
|
||||
**Event handling strategy:**
|
||||
1. On initial page load: fetch summary via REST + list via REST.
|
||||
2. Connect to `/ws/task-events`.
|
||||
3. Buffer incoming events for 100ms (debounce window).
|
||||
4. After 100ms: apply all buffered events to the in-memory `tasks` map:
|
||||
- New task → insert at top of list.
|
||||
- Status change → update task in-place.
|
||||
- Task completion → update status, mark finished_at.
|
||||
5. `$derived` recomputes summary from updated task list.
|
||||
6. If WebSocket disconnects → indicator + auto-reconnect with exponential backoff (1s, 2s, 4s, 8s, max 30s). During disconnect, data remains visible with "stale" indicator.
|
||||
|
||||
**Impact:** No backend changes for WebSocket. Frontend `TaskCenterModel` gains WebSocket subscription logic.
|
||||
|
||||
---
|
||||
|
||||
## R3: Screen Model Architecture
|
||||
|
||||
**Decision:** Create `TaskCenterModel` (`frontend/src/lib/models/TaskCenterModel.svelte.ts`) as a C4 Screen Model. Single model for initial implementation (expected ~250-300 lines, ~15-20 atoms + methods). If it crosses 400 lines or 40 methods, decompose per ADR-0010 into `TaskCenterSummaryModel` + `TaskCenterFilterModel` + `TaskCenterListModel`.
|
||||
|
||||
**Rationale:**
|
||||
- The spec has 3 user stories (summary, filtering, drill-down) — well within single-model scope.
|
||||
- ADR-0010 decomposition gate is 400 lines / 40 methods. A model handling summary + list + filters is estimated at ~250 lines initially.
|
||||
- Premature decomposition adds indirection (`m.filters.taskType` vs `m.taskType`) without proportional benefit.
|
||||
|
||||
**Model structure:**
|
||||
```typescript
|
||||
// @STATE atoms:
|
||||
tasks: TaskReport[] // Full task list
|
||||
summary: TaskSummary | null // Aggregated counts
|
||||
filters: TaskFilter // Current filter state
|
||||
screenState: ScreenState // idle | loading | empty | disconnected | error
|
||||
wsConnected: boolean
|
||||
selectedTaskId: string | null
|
||||
|
||||
// @DERIVED:
|
||||
filteredTasks: TaskReport[] // Derived from tasks + filters
|
||||
activeTasksFirst: TaskReport[] // Sort: active before completed
|
||||
|
||||
// @ACTION methods:
|
||||
loadInitialData()
|
||||
applyFilter(partial: Partial<TaskFilter>)
|
||||
clearFilters()
|
||||
selectTask(taskId: string)
|
||||
refreshSummary()
|
||||
connectWebSocket()
|
||||
disconnectWebSocket()
|
||||
```
|
||||
|
||||
**Rejected:**
|
||||
- **Keeping all logic in `+page.svelte`** — violates ADR-0006 Model-first pattern. The page file becomes a thin render layer.
|
||||
- **Submodels from day one** — premature decomposition when the model is <300 lines. Creates unnecessary file sprawl.
|
||||
- **Global store (`.svelte.ts` module-level)** — the task center state is page-scoped, not cross-route. Global store would keep stale data in memory after navigation.
|
||||
|
||||
**Impact:** New file `frontend/src/lib/models/TaskCenterModel.svelte.ts`. Page `+page.svelte` becomes a thin render layer (~60 lines).
|
||||
|
||||
---
|
||||
|
||||
## R4: RBAC Row-Level Filtering
|
||||
|
||||
**Decision:** Implement user-aware task filtering in `reports_service.py`. The `list_reports()` and new `get_summary()` methods will filter `task_manager.get_all_tasks()` based on the authenticated user's role BEFORE normalization:
|
||||
|
||||
| Role | Visible tasks |
|
||||
|------|--------------|
|
||||
| `admin` | All tasks (all users + system) |
|
||||
| `analyst` | Own tasks (`task.user_id == current_user.id`) + system tasks (`task.user_id is None`) |
|
||||
| `viewer` | Only own tasks (`task.user_id == current_user.id`) |
|
||||
|
||||
**Rationale:**
|
||||
- The spec (TSC-FR-009) explicitly requires RBAC on task visibility. Currently `has_permission("tasks", "READ")` only gates access — it doesn't filter rows.
|
||||
- The `Task` Pydantic model already has a `user_id` field (str | None). No schema changes needed.
|
||||
- Role determined from JWT token (already available via `Depends(get_current_user)`).
|
||||
|
||||
**Rejected:**
|
||||
- **Filtering at TaskGraph level** — the graph is an in-memory task registry and should not have RBAC awareness. Separation of concerns: graph manages tasks, service enforces access.
|
||||
- **Adding `owner_id` column to TaskRecord** — unnecessary DB migration. The in-memory `Task.user_id` already carries this data. Persisted tasks already have this info.
|
||||
- **ABAC (Attribute-Based Access Control)** — rejected by ADR-0005. RBAC is sufficient for this scope.
|
||||
|
||||
**Impact:** `ReportsService` gains a new parameter `current_user: User`. Filtering happens in `_load_normalized_reports()` before normalization.
|
||||
|
||||
---
|
||||
|
||||
## R5: Filter State Persistence
|
||||
|
||||
**Decision:** Store filter state in URL query parameters (`?type=migration,backup&status=running,failed&search=dashboard&time_from=...&time_to=...`). On page load, parse from URL. On filter change, update URL via `history.replaceState()` (NOT `pushState`).
|
||||
|
||||
**Rationale:**
|
||||
- SvelteKit convention — URL is the source of truth for page state.
|
||||
- Survives page refresh (browser restores URL).
|
||||
- Survives browser back/forward navigation.
|
||||
- Enables URL sharing (operator can send link to filtered view).
|
||||
- `replaceState` prevents back-button pollution from every keystroke in the search box.
|
||||
|
||||
**Rejected:**
|
||||
- **LocalStorage persistence** — doesn't survive URL sharing. State hidden from URL bar.
|
||||
- **SessionStorage** — lost on tab close, no sharing.
|
||||
- **`pushState` for every filter change** — would create dozens of history entries from a single search session. Back button becomes unusable.
|
||||
|
||||
**Implementation:**
|
||||
- SvelteKit `+page.ts` load function reads `url.searchParams` and returns initial `TaskFilter`.
|
||||
- `TaskCenterModel` constructor accepts initial filter from load function.
|
||||
- `$effect` watches filter changes and calls `replaceState` with new query params.
|
||||
- Debounce URL update to 300ms (avoid flooding browser history API).
|
||||
|
||||
**Impact:** New `+page.ts` load function. Model gains `syncToUrl()` action.
|
||||
|
||||
---
|
||||
|
||||
## R6: WebSocket Event Debounce Strategy
|
||||
|
||||
**Decision:** Buffer incoming events in a 100ms window using `setTimeout` accumulation. After each 100ms window, apply all buffered events to the in-memory task list atomically, then let `$derived` recompute the filtered list and summary.
|
||||
|
||||
**Rationale:**
|
||||
- During mass task completion (edge case: batch translation completes 50 tasks simultaneously), `broadcast_status` fires 50 times on the global event bus. Without debounce, the UI would thrash: 50 DOM updates in a single frame.
|
||||
- 100ms window is below human perception threshold (200ms) while above browser frame budget (16ms). UI feels instant.
|
||||
- Svelte 5 `$derived` is microtask-scheduled — after applying all buffered events synchronously, the derived values update once.
|
||||
|
||||
**Rejected:**
|
||||
- **Throttle (fixed interval)** — can miss the last event in a burst. If events arrive at t=90ms and throttle fires at t=100ms, with a 200ms throttle, the next fire is at t=290ms — 190ms stale.
|
||||
- **`requestAnimationFrame` for each event** — each event triggers a separate DOM update. During burst, this starves the render pipeline.
|
||||
- **No debounce** — spec SC-004 requires 30fps at 100+ concurrent tasks. Without debounce, this is impossible.
|
||||
|
||||
**Impact:** Model adds `_eventBuffer: TaskStatusEvent[]` and `_flushTimeout: number | null`.
|
||||
|
||||
---
|
||||
|
||||
## R7: Frontend-Backend Type Synchronization
|
||||
|
||||
**Decision:** Create TypeScript interfaces in `frontend/src/types/reports.ts` matching the backend Pydantic schemas with `@DATA_CONTRACT` cross-stack annotations. Add CI enforcement that backend schema changes trigger frontend type review.
|
||||
|
||||
**Rationale:**
|
||||
- Currently the reports API client uses generic `<T>` — no TypeScript compile-time protection against field mismatches.
|
||||
- When backend `TaskReport` gains a new field, frontend code using `report.newField` won't cause a TypeScript error — it will silently be `undefined`.
|
||||
- `@DATA_CONTRACT` on both backend Pydantic `TaskReport` and frontend `interface TaskReport` creates a bidirectional contract reference that survives HCA 128× cross-stack amnesia.
|
||||
|
||||
**Type mapping:**
|
||||
|
||||
| Backend (Pydantic) | Frontend (TypeScript) |
|
||||
|---------------------|----------------------|
|
||||
| `TaskType` (str enum) | `type TaskType = 'llm_verification' \| 'backup' \| 'migration' \| 'documentation' \| 'clean_release' \| 'unknown'` |
|
||||
| `ReportStatus` (str enum) | `type ReportStatus = 'success' \| 'failed' \| 'in_progress' \| 'partial'` |
|
||||
| `TaskReport` | `interface TaskReport { report_id, task_id, task_type, status, ... }` |
|
||||
| `ReportQuery` | `interface ReportQuery { page, page_size, task_types, ... }` |
|
||||
| `ReportCollection` | `interface ReportCollection { items, total, page, ... }` |
|
||||
| `ReportDetailView` | `interface ReportDetailView { report, timeline, ... }` |
|
||||
| **NEW** `TaskSummary` | `interface TaskSummary { by_type_and_status, total_tasks, ... }` |
|
||||
|
||||
**Rejected:**
|
||||
- **OpenAPI code generation** — adds build step complexity. The schema set is small (<10 interfaces). Manual sync with CI review is proportionate.
|
||||
- **Runtime validation (Zod)** — unnecessary. Backend Pydantic already validates on response. Frontend gets typed JSON. Adding Zod doubles the schema surface for no gain.
|
||||
|
||||
**Impact:** New file `frontend/src/types/reports.ts`. Updated `reports.ts` API client to use typed responses.
|
||||
|
||||
---
|
||||
|
||||
## R8: Task Type List Synchronization
|
||||
|
||||
**Decision:** Frontend `reportTypeProfiles.ts` must be synchronized with backend `type_profiles.py`. Add entries for all backend `TaskType` enum values that are missing: `clean_release`, and all task types from the spec (`translate`, `validation`, `git`, `storage`, `debug`, `search`, `mapper`, `maintenance`).
|
||||
|
||||
**Rationale:**
|
||||
- Spec lists 13 task types: translate, migration, backup, validation, documentation, git, storage, debug, search, mapper, maintenance, clean_release, unknown.
|
||||
- Backend `TaskType` enum has 6: LLM_VERIFICATION, BACKUP, MIGRATION, DOCUMENTATION, CLEAN_RELEASE, UNKNOWN.
|
||||
- Frontend `reportTypeProfiles.ts` has 5: llm_verification, backup, migration, documentation, unknown. Missing: clean_release.
|
||||
- The `PLUGIN_TO_TASK_TYPE` mapping in the backend maps plugin IDs toTaskType — the spec's broader list maps to these 6 canonical types. The frontend should mirror the 6 canonical types.
|
||||
|
||||
**Decision:** Frontend profiles match backend `TaskType` enum exactly (6 types + UNKNOWN fallback). Plugin-level types (translate, validation, git, etc.) are sub-types resolved by the backend — frontend only sees canonical types.
|
||||
|
||||
**Impact:** Update `reportTypeProfiles.ts` to add `clean_release` profile.
|
||||
|
||||
---
|
||||
|
||||
## R9: Active-Tasks-First Sorting
|
||||
|
||||
**Decision:** Implement two-level sort: first by `is_active` (desc), then by user-selected sort field. Active = status IN (PENDING, RUNNING, AWAITING_INPUT). This satisfies TSC-FR-010 without a dedicated API parameter.
|
||||
|
||||
**Rationale:**
|
||||
- The spec requires active tasks always shown first regardless of sort order.
|
||||
- This is a UI concern, not a data concern. Implement in the Model's `$derived` rather than adding a backend parameter.
|
||||
- Two-level sort: `is_active DESC, sort_field ASC/DESC`.
|
||||
|
||||
**Rejected:**
|
||||
- **Backend sort parameter `active_first=true`** — adds complexity to query parsing without real benefit. The full task list is already loaded in-memory.
|
||||
- **Separate API endpoint for active tasks** — fragments the data model unnecessarily.
|
||||
|
||||
**Impact:** `TaskCenterModel.filteredTasks` `$derived` expression gains two-level sort.
|
||||
|
||||
---
|
||||
|
||||
## R10: Existing WebSocket `/ws/task-events` Authentication
|
||||
|
||||
**Decision:** No changes needed. The existing endpoint authenticates via query parameter `?token=JWT`. Frontend `getTaskEventsWsUrl()` helper already constructs this URL from `localStorage.auth_token`. The JWT contains `user_id` and `roles` — the backend `_authenticate_websocket()` already validates this.
|
||||
|
||||
**Rationale:**
|
||||
- Existing infrastructure works. No code changes.
|
||||
- The WebSocket endpoint does NOT currently filter events by user — it broadcasts ALL task status changes to ALL connected clients. This is acceptable because:
|
||||
1. Task status metadata (id, plugin_id, status, timestamps) is not sensitive.
|
||||
2. The REST API enforces RBAC on detail (logs, results, error messages).
|
||||
3. If future requirements demand it, filtering can be added at the `EventBus` subscriber level.
|
||||
|
||||
**Impact:** Zero backend changes for WebSocket.
|
||||
|
||||
## Summary of Decisions
|
||||
|
||||
| ID | Decision | Backend Impact | Frontend Impact |
|
||||
|----|----------|---------------|-----------------|
|
||||
| R1 | New `GET /api/reports/summary` | New route, schema, service method | New API client, TypeScript DTO |
|
||||
| R2 | Reuse `/ws/task-events` | None | Model gains WS subscription |
|
||||
| R3 | Single `TaskCenterModel` | None | New model file |
|
||||
| R4 | RBAC row-level filtering | Service method change | None |
|
||||
| R5 | URL query param persistence | None | `+page.ts` + model method |
|
||||
| R6 | 100ms debounce window | None | Model event buffering |
|
||||
| R7 | TypeScript DTOs + `@DATA_CONTRACT` | Add `@DATA_CONTRACT` annotations | New `types/reports.ts` |
|
||||
| R8 | Frontend profiles match backend `TaskType` | None | Add `clean_release` profile |
|
||||
| R9 | Two-level sort in `$derived` | None | Model derived expression |
|
||||
| R10 | No WS auth changes | None | None |
|
||||
|
||||
#endregion ResearchDoc
|
||||
Reference in New Issue
Block a user