feat(reports): Task Status Center — unified /reports dashboard

Страница /reports трансформирована в Центр статусов задач:

Backend:
- GET /api/reports/summary — агрегированные счётчики тип×статус (5 корзин)
- GET/PUT /api/settings/reports — глобальные настройки отчётов
- _filter_tasks_by_rbac() — row-level фильтрация по роли
- normalize_task_report: LLM-валидация с ошибками → FAILED/PARTIAL
- get_summary(): 5 корзин pending/running/awaiting_input/success/failed

Frontend:
- TaskCenterModel.svelte.ts (400 строк) — Screen Model
- SummaryPanel — сводная панель с цветовым кодированием и active filter
- ReportCard — humanized labels, duration, task_id, failed border
- FilterBar — search + sort + time range с label'ами
- Pagination — showingText, уникальные id для select
- Quick views: «Упавшие», «В работе», «Успешные»
- TaskDrawer: scroll-to-error, footer скрыт для terminal, «Н/Д» fix

Тесты: 48 backend + 38 frontend (2521 всего)
Build:   Console errors: 0
This commit is contained in:
2026-07-02 18:53:58 +03:00
parent 8c10632494
commit 33ee976c48
33 changed files with 3536 additions and 218 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
// #region ReportsApi [C:3] [TYPE Module] [SEMANTICS report, api, search, filter, query]
// @BRIEF Reports API client — list/detail retrieval, query-string building, error normalization.
// @BRIEF Reports API client — list/detail retrieval, query-string building, error normalization, typed summary endpoint.
// @LAYER API
// @RELATION DEPENDS_ON -> [ApiModule]
// @RELATION DEPENDS_ON -> [ReportsTypes]
// @PRE Shared API wrapper is configured for the current frontend auth/session context.
// @POST Report list and detail helpers return backend payloads or normalized UI-safe errors.
import { api } from '../api';
import type { ReportCollection, ReportDetailView, TaskSummary, ReportsSettings } from '$types/reports';
// #region reportApiTypes [C:1] [TYPE Block] [SEMANTICS types, reports]
interface ReportQueryOptions {
@@ -75,13 +77,13 @@ export function normalizeApiError(error: unknown): NormalizedError {
// #region getReports [C:3] [TYPE Function] [SEMANTICS report, list, fetch]
// @BRIEF Fetch unified report list using the shared API wrapper.
// @PRE Valid auth context for protected endpoint.
// @POST Returns parsed payload or throws normalized error.
// @POST Returns parsed ReportCollection or throws normalized error.
// @SIDE_EFFECT Performs authenticated GET request through api.fetchApi.
// @RELATION DEPENDS_ON -> [fetchApi]
export async function getReports<T = unknown>(options: ReportQueryOptions = {}): Promise<T> {
export async function getReports(options: ReportQueryOptions = {}): Promise<ReportCollection> {
try {
const query = buildReportQueryString(options);
return await api.fetchApi<T>(`/reports${query ? `?${query}` : ''}`);
return await api.fetchApi<ReportCollection>(`/reports${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeApiError(error);
}
@@ -91,15 +93,43 @@ export async function getReports<T = unknown>(options: ReportQueryOptions = {}):
// #region getReportDetail [C:3] [TYPE Function] [SEMANTICS report, detail, fetch]
// @BRIEF Fetch one report detail by report_id.
// @PRE reportId is non-empty string; valid auth context.
// @POST Returns parsed detail payload or throws normalized error.
// @POST Returns parsed ReportDetailView or throws normalized error.
// @SIDE_EFFECT Performs authenticated GET request through api.fetchApi.
// @RELATION DEPENDS_ON -> [fetchApi]
export async function getReportDetail<T = unknown>(reportId: string): Promise<T> {
export async function getReportDetail(reportId: string): Promise<ReportDetailView> {
try {
return await api.fetchApi<T>(`/reports/${reportId}`);
return await api.fetchApi<ReportDetailView>(`/reports/${reportId}`);
} catch (error) {
throw normalizeApiError(error);
}
}
// #endregion getReportDetail
// #region getReportsSummary [C:2] [TYPE Function] [SEMANTICS report, summary, fetch]
// @BRIEF Fetch aggregated task summary for the summary dashboard.
// @PRE Valid auth context.
// @POST Returns parsed TaskSummary or throws normalized error.
// @SIDE_EFFECT Performs authenticated GET request through api.fetchApi.
export async function getReportsSummary(): Promise<TaskSummary> {
try {
return await api.fetchApi<TaskSummary>('/reports/summary');
} catch (error) {
throw normalizeApiError(error);
}
}
// #endregion getReportsSummary
// #region getReportsSettings [C:2] [TYPE Function] [SEMANTICS report, settings, fetch]
// @BRIEF Fetch reports settings (admin-disabled task types).
export async function getReportsSettings(): Promise<ReportsSettings> {
return await api.fetchApi<ReportsSettings>('/settings/reports');
}
// #endregion getReportsSettings
// #region updateReportsSettings [C:2] [TYPE Function] [SEMANTICS report, settings, update]
// @BRIEF Update reports settings (admin-only: disabled task types).
export async function updateReportsSettings(settings: ReportsSettings): Promise<ReportsSettings> {
return await api.requestApi<ReportsSettings>('/settings/reports', 'PUT', settings);
}
// #endregion updateReportsSettings
// #endregion ReportsApi

View File

@@ -82,6 +82,23 @@
let isOpen = $derived(Boolean(taskDrawerStore.value?.isOpen));
let activeTaskId = $derived(taskDrawerStore.value?.activeTaskId || null);
let scrollToHint = $derived(taskDrawerStore.value?.scrollToHint || null);
// Scroll to last error when opening a FAILED task
$effect(() => {
if (scrollToHint === 'last_error' && activeTaskId) {
const timeout = setTimeout(() => {
const drawer = document.querySelector('[role="dialog"]');
if (drawer) {
const logs = drawer.querySelectorAll('.text-destructive');
const lastError = logs[logs.length - 1];
if (lastError) lastError.scrollIntoView({ behavior: 'smooth', block: 'center' });
else drawer.scrollTop = drawer.scrollHeight;
}
}, 600);
return () => clearTimeout(timeout);
}
});
let isAssistantOpen = $derived(Boolean(assistantChatStore.value?.isOpen));
let assistantOffset = $derived(
isAssistantOpen ? "min(100vw, 28rem)" : "0px",
@@ -718,7 +735,7 @@
disabled={!derivedTaskSummary?.primaryDashboardId ||
!derivedTaskSummary?.targetEnvId}
>
{#if derivedTaskSummary?.targetEnvName}
{#if derivedTaskSummary?.targetEnvName && derivedTaskSummary.targetEnvName !== '\u041D/\u0414'}
{(
$t.tasks?.open_dashboard_target || "Open dashboard in {env}"
).replace("{env}", derivedTaskSummary.targetEnvName)}
@@ -880,7 +897,8 @@
{/if}
</div>
<!-- Footer -->
<!-- Footer — hidden for terminal states -->
{#if taskStatus !== "SUCCESS" && taskStatus !== "FAILED"}
<div
class="flex items-center gap-2 justify-center px-4 py-3 border-t border-border bg-surface-page"
>
@@ -889,6 +907,7 @@
{$t.tasks?.footer_text}
</p>
</div>
{/if}
</div>
<!-- #endregion TaskDrawer -->
{/if}

View File

@@ -33,7 +33,7 @@
import { t } from "$lib/i18n/index.svelte.js";
import { getReportTypeProfile } from "./reportTypeProfiles.js";
import { formatDateTime } from "$lib/utils/dateFormat";
import { formatDateTime, formatDuration } from "$lib/utils/dateFormat.js";
let { report, selected = false, onselect = () => {} } = $props();
@@ -68,9 +68,9 @@
</script>
<button
class="w-full rounded-xl border p-4 text-left shadow-sm transition hover:border-border-strong hover:bg-surface-page hover:shadow {selected
class="w-full rounded-xl border p-4 text-left transition hover:border-border-strong hover:bg-surface-page hover:shadow {selected
? 'border-primary-ring bg-primary-light'
: 'border-border bg-surface-card'}"
: 'border-border bg-surface-card'} {report?.status === 'failed' ? 'border-l-4 border-l-destructive-ring' : ''}"
onclick={onSelect}
aria-label={`${$t.reports?.title} ${report?.report_id || ""} ${$t.reports?.type} ${profileLabel || $t.reports?.unknown_type}`}
>
@@ -92,7 +92,21 @@
<p class="text-sm font-medium text-text">
{report?.summary || $t.reports?.not_provided}
</p>
<p class="mt-1 text-xs text-text-muted">{formatDateTime(report?.updated_at)}</p>
{#if report?.status === 'in_progress' && report?.error_context?.code === 'AWAITING_INPUT'}
<div class="mt-1.5 flex items-center gap-2">
<span class="bg-warning-light text-warning rounded-full px-2 py-0.5 text-xs font-medium">
Ожидает ввода
</span>
</div>
{/if}
<div class="mt-1.5 flex items-center justify-between gap-2 text-[11px] text-text-muted">
<span class="font-mono truncate">{report?.task_id?.slice(0, 8) || report?.report_id?.slice(0, 8) || 'N/A'}</span>
{#if report?.started_at && report?.updated_at}
<span>{formatDuration(report.started_at, report.updated_at)}</span>
{:else if report?.updated_at}
<span>{formatDateTime(report.updated_at)}</span>
{/if}
</div>
</button>
<!-- #endregion ReportCard -->

View File

@@ -0,0 +1,95 @@
<!-- #region SummaryPanel [C:4] [TYPE Component] [SEMANTICS component, summary, dashboard, websocket-reactive, task-status-center] -->
<!-- @BRIEF Summary dashboard panel — grid of clickable cards showing task counts by type × status. -->
<!-- @LAYER UI Component -->
<!-- @UX_STATE idle: cards rendered normally, clickable -->
<!-- @UX_STATE loading: skeleton placeholders (4 cards with pulse animation) -->
<!-- @UX_STATE empty: all cards show zero counts -->
<!-- @UX_STATE reconnecting: last known counts visible, yellow pulsing indicator -->
<!-- @UX_STATE disconnected: last known counts visible, opacity reduced, "disconnected" badge -->
<!-- @UX_FEEDBACK Count change: number animates (CSS transition). Card click: emits filter event. -->
<!-- @UX_RECOVERY Disconnected: auto-reconnect indicator. Click → manual reconnect button. -->
<!-- @UX_REACTIVITY Props: summary: TaskSummary | null, screenState: ScreenState. Derived: by_type grid. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
<!-- @PRE summary is non-null when screenState is 'ready' or 'disconnected'. -->
<!-- @POST Clicking a card calls onFilterByTypeAndStatus(taskType, status). -->
<script lang="ts">
import type { TaskType, ReportStatus, ScreenState, StatusCounts, TaskSummary } from '$types/reports';
const STATUS_CONFIG: { key: keyof StatusCounts; label: string; colorClass: string }[] = [
{ key: 'running', label: 'Выполняется', colorClass: 'bg-primary/10 text-primary' },
{ key: 'pending', label: 'Ожидает', colorClass: 'bg-surface-muted text-text-muted' },
{ key: 'awaiting_input', label: 'Ожидает ввода', colorClass: 'bg-warning-light text-warning' },
{ key: 'success', label: 'Успешно', colorClass: 'bg-success-light text-success' },
{ key: 'failed', label: 'Упало', colorClass: 'bg-destructive-light text-destructive' },
];
interface Props {
summary: TaskSummary | null;
screenState: ScreenState;
onFilterByTypeAndStatus: (_type: TaskType, _status: ReportStatus) => void;
onReconnect?: () => void;
activeFilters?: { task_types: TaskType[]; statuses: ReportStatus[] };
}
let { summary, screenState, onFilterByTypeAndStatus, onReconnect = () => {}, activeFilters = { task_types: [], statuses: [] } }: Props = $props();
function getCount(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): number {
const c = typeSummary.counts;
return c[statusKey] ?? 0;
}
function handleCardClick(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): void {
// Map summary status keys to ReportStatus for filtering
const statusMap: Record<string, ReportStatus> = {
running: 'in_progress',
pending: 'in_progress',
awaiting_input: 'in_progress',
success: 'success',
failed: 'failed',
};
onFilterByTypeAndStatus(typeSummary.task_type, statusMap[statusKey] ?? 'in_progress');
}
</script>
{#if screenState === 'loading'}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{#each [1, 2, 3, 4] as n (n)}
<div class="bg-surface-muted rounded-lg h-20 animate-pulse"></div>
{/each}
</div>
{:else if summary && summary.by_type.length > 0}
<div class="space-y-3">
{#each summary.by_type as typeSummary (typeSummary.task_type)}
<!-- Type header -->
<div class="flex items-center gap-2 mb-1">
<span class="text-text text-sm font-medium">{typeSummary.display_label}</span>
<span class="text-text-muted text-xs">({typeSummary.total})</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
{#each STATUS_CONFIG as status (status.key)}
{@const count = getCount(typeSummary, status.key)}
{@const isActive = activeFilters.task_types.includes(typeSummary.task_type) && activeFilters.statuses.includes(status.key as ReportStatus)}
<button
class="flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-all hover:border-border-strong {screenState === 'disconnected' ? 'opacity-60' : ''} {status.colorClass} {isActive ? 'ring-2 ring-primary ring-offset-1' : ''}"
onclick={() => handleCardClick(typeSummary, status.key)}
aria-label="{typeSummary.display_label}, {status.label}: {count}"
>
<span class="text-sm font-mono min-w-[1.5rem] text-center font-semibold">{count}</span>
<span class="text-xs truncate">{status.label}</span>
</button>
{/each}
</div>
{/each}
{#if screenState === 'disconnected'}
<div class="flex items-center gap-2 text-xs text-text-muted mt-2">
<span class="w-2 h-2 rounded-full bg-destructive"></span>
Данные могут быть неактуальны
<button class="underline cursor-pointer" onclick={onReconnect}>Подключиться</button>
</div>
{/if}
</div>
{:else}
<!-- empty state - just placeholder -->
<div class="text-text-muted text-sm py-4">Нет активных задач</div>
{/if}
<!-- #endregion SummaryPanel -->

View File

@@ -0,0 +1,130 @@
/**
* @vitest-environment jsdom
*/
// #region SummaryPanelTest [C:3] [TYPE Module] [SEMANTICS test,summary,ux,component]
// @BRIEF UX tests for SummaryPanel component — states, transitions, click events.
// @RELATION BINDS_TO -> [SummaryPanel]
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import SummaryPanel from '../SummaryPanel.svelte';
import type { TaskSummary, ScreenState } from '$types/reports';
// Mock i18n
vi.mock('$lib/i18n/index.svelte.js', () => ({
t: { subscribe: (fn: any) => { fn({}); return () => {}; } },
_: vi.fn((key: string) => key),
}));
const makeTypeSummary = (overrides = {}) => ({
task_type: 'llm_verification' as const,
display_label: 'llm_verification',
icon_token: '',
counts: { pending: 2, running: 3, awaiting_input: 1, success: 15, failed: 5 },
total: 26,
...overrides,
});
const makeSummary = (overrides = {}): TaskSummary => ({
by_type: [makeTypeSummary()],
total_tasks: 26,
active_tasks: 6,
...overrides,
});
describe('SummaryPanel — UX states', () => {
const onFilter = vi.fn();
const onReconnect = vi.fn();
beforeEach(() => vi.clearAllMocks());
// @UX_STATE: loading → skeleton placeholders (4 cards with pulse animation)
it('shows skeleton cards in loading state', () => {
const { container } = render(SummaryPanel, {
summary: null,
screenState: 'loading' as ScreenState,
onFilterByTypeAndStatus: onFilter,
});
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThanOrEqual(4);
});
// @UX_STATE: ready → cards rendered with counts
it('renders type header and status counts in ready state', () => {
render(SummaryPanel, {
summary: makeSummary(),
screenState: 'ready' as ScreenState,
onFilterByTypeAndStatus: onFilter,
});
expect(screen.getByText('llm_verification')).toBeDefined();
expect(screen.getByText('(26)')).toBeDefined();
});
// @UX_FEEDBACK: card click → calls onFilterByTypeAndStatus
it('clicking a status card calls filter callback', async () => {
render(SummaryPanel, {
summary: makeSummary(),
screenState: 'ready' as ScreenState,
onFilterByTypeAndStatus: onFilter,
});
const successButton = screen.getByLabelText('llm_verification, Успешно: 15');
await fireEvent.click(successButton);
expect(onFilter).toHaveBeenCalledWith('llm_verification', 'success');
});
// @UX_STATE: empty → all zeros shown
it('shows zero counts when summary has empty by_type', () => {
const empty = makeSummary({ by_type: [], total_tasks: 0, active_tasks: 0 });
render(SummaryPanel, {
summary: empty,
screenState: 'ready' as ScreenState,
onFilterByTypeAndStatus: onFilter,
});
expect(screen.getByText('Нет активных задач')).toBeDefined();
});
// @UX_STATE: disconnected → opacity reduced + reconnect link
it('shows disconnected indicator in disconnected state', () => {
render(SummaryPanel, {
summary: makeSummary(),
screenState: 'disconnected' as ScreenState,
onFilterByTypeAndStatus: onFilter,
onReconnect,
});
expect(screen.getByText('Данные могут быть неактуальны')).toBeDefined();
const reconnectBtn = screen.getByText('Подключиться');
expect(reconnectBtn).toBeDefined();
});
// @UX_RECOVERY: reconnect click → calls onReconnect
it('clicking reconnect button calls onReconnect', async () => {
render(SummaryPanel, {
summary: makeSummary(),
screenState: 'disconnected' as ScreenState,
onFilterByTypeAndStatus: onFilter,
onReconnect,
});
await fireEvent.click(screen.getByText('Подключиться'));
expect(onReconnect).toHaveBeenCalledOnce();
});
// @UX_STATE: multiple types rendered correctly
it('renders multiple task types with their counts', () => {
const multi = makeSummary({
by_type: [
makeTypeSummary({ task_type: 'backup' as const, display_label: 'backup', total: 5, counts: { pending: 1, running: 0, awaiting_input: 0, success: 4, failed: 0 } }),
makeTypeSummary({ task_type: 'unknown' as const, display_label: 'unknown', total: 3, counts: { pending: 0, running: 0, awaiting_input: 0, success: 2, failed: 1 } }),
],
total_tasks: 8,
});
render(SummaryPanel, {
summary: multi,
screenState: 'ready' as ScreenState,
onFilterByTypeAndStatus: onFilter,
});
expect(screen.getByText('backup')).toBeDefined();
expect(screen.getByText('unknown')).toBeDefined();
// Second type has 1 failed
expect(screen.getByLabelText('unknown, Упало: 1')).toBeDefined();
});
});
// #endregion SummaryPanelTest

View File

@@ -40,6 +40,13 @@ export const REPORT_TYPE_PROFILES: Record<string, ReportTypeProfile> = {
icon: 'file-text',
fallback: false
},
clean_release: {
key: 'clean_release',
label: 'Clean Release',
variant: 'bg-teal-100 text-teal-700',
icon: 'shield-check',
fallback: false
},
unknown: {
key: 'unknown',
label: () => _('reports.unknown_type'),

View File

@@ -0,0 +1,399 @@
// #region TaskCenter.Model [C:4] [TYPE Model] [SEMANTICS model,task-center,summary,filtering,websocket,task-status-center]
// @defgroup TaskCenter Screen Model for the Task Status Center (/reports).
// @LAYER Store
// @PRE Auth token in localStorage, user authenticated via root +layout.svelte.
// @INVARIANT Active tasks always sorted before completed in filteredTasks. visibleSummary excludes disabledTypes AND not in visibleTypes. WS: connect on load, disconnect on destroy. DECOMPOSITION GATE: 400 lines, 40 methods (ADR-0010).
// @STATE tasks, summary, filters, screenState, wsConnected, selectedTaskId, page, pageSize, disabledTypes, visibleTypes, lastUpdated
// @ACTION loadInitialData, applyFilter, clearFilters, selectTask, refreshSummary, connectWebSocket, disconnectWebSocket, destroy, toggleTypeVisibility
// @RELATION CALLS -> [ReportsApi]
// @RELATION CALLS -> [ApiModule.getTaskEventsWsUrl]
// @RELATION BINDS_TO -> [TaskDrawerStore]
// @RELATION BINDS_TO -> [SummaryPanel]
// @RELATION BINDS_TO -> [FilterBar]
// @RELATION BINDS_TO -> [TaskList]
// @DATA_CONTRACT Input: TaskCenterFilter → Output: filteredTasks: TaskReport[], visibleSummary: TaskSummary
// @POST State mutations trigger $derived recompute. WS lifecycle managed.
// @SIDE_EFFECT WS connect/disconnect, fetch API, URL replaceState, openDrawerForTask, localStorage read/write.
// @RATIONALE Single model (R3) — 3 stories share same task dataset, ~350 lines, below 400-line gate.
// @REJECTED Submodels from day one (premature), global store (page-scoped), polling (spec requires WS real-time).
import { log } from '$lib/cot-logger';
import { api } from '$lib/api.js';
import { getTaskEventsWsUrl } from '$lib/api.js';
import { openDrawerForTask } from '$lib/stores/taskDrawer.svelte.js';
import { addToast } from '$lib/toasts.svelte.js';
import type {
TaskType,
ReportStatus,
ScreenState,
TaskReport,
TaskSummary,
TaskCenterFilter,
TaskStatusEvent,
} from '$types/reports';
export type { TaskType, ReportStatus, ScreenState, TaskReport, TaskSummary, TaskCenterFilter };
// ── Constants ──
const ALL_TYPES: TaskType[] = ['llm_verification', 'backup', 'migration', 'documentation', 'clean_release', 'unknown'];
const DEBOUNCE_MS = 100;
const URL_SYNC_MS = 300;
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000];
const STORAGE_KEY = 'taskCenter.visibleTypes';
const DEFAULT_FILTERS: TaskCenterFilter = {
task_types: [],
statuses: [],
search: '',
time_range: 'all',
sort_by: 'updated_at',
sort_order: 'desc',
};
// ── Helpers ──
function isActiveStatus(status: ReportStatus): boolean {
return status === 'in_progress';
}
function loadVisibleTypes(): TaskType[] {
if (typeof localStorage === 'undefined') return [...ALL_TYPES];
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as TaskType[];
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch { /* ignore parse errors */ }
return [...ALL_TYPES];
}
function timeRangeToFrom(range: TaskCenterFilter['time_range']): Date | null {
if (range === 'all') return null;
const now = new Date();
switch (range) {
case '1h': return new Date(now.getTime() - 60 * 60 * 1000);
case '24h': return new Date(now.getTime() - 24 * 60 * 60 * 1000);
case '7d': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case '30d': return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
default: return null;
}
}
// ── Model ──
export class TaskCenterModel {
// === ATOMS ===
tasks: TaskReport[] = $state([]);
summary: TaskSummary | null = $state(null);
filters: TaskCenterFilter = $state({ ...DEFAULT_FILTERS });
screenState: ScreenState = $state('loading');
wsConnected: boolean = $state(false);
selectedTaskId: string | null = $state(null);
page: number = $state(1);
pageSize: number = $state(20);
disabledTypes: TaskType[] = $state([]);
visibleTypes: TaskType[] = $state(loadVisibleTypes());
lastUpdated: Date | null = $state(null);
private _ws: WebSocket | null = null;
private _eventBuffer: TaskStatusEvent[] = [];
private _flushTimeout: ReturnType<typeof setTimeout> | null = null;
private _reconnectAttempt: number = 0;
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private _urlSyncTimeout: ReturnType<typeof setTimeout> | null = null;
totalItems: number = $state(0);
private _hasNextPage: boolean = false;
// === DERIVED ===
get filteredTasks(): TaskReport[] {
// Server-side filtered + sorted by sort_by/sort_order.
// Client-side sort only for active-first ordering and WS-updated tasks.
return [...this.tasks].sort((a, b) => {
const aActive = isActiveStatus(a.status) ? 1 : 0;
const bActive = isActiveStatus(b.status) ? 1 : 0;
if (aActive !== bActive) return bActive - aActive;
const dir = this.filters.sort_order === 'asc' ? 1 : -1;
switch (this.filters.sort_by) {
case 'status': return a.status.localeCompare(b.status) * dir;
case 'task_type': return a.task_type.localeCompare(b.task_type) * dir;
case 'created_at':
case 'updated_at':
default:
return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * dir;
}
});
}
get visibleSummary(): TaskSummary | null {
if (!this.summary) return null;
const filteredByType = this.summary.by_type.filter(
(ts) => !this.disabledTypes.includes(ts.task_type) && this.visibleTypes.includes(ts.task_type)
);
const totalTasks = filteredByType.reduce((sum, ts) => sum + ts.total, 0);
const activeTasks = filteredByType.reduce(
(sum, ts) => sum + ts.counts.pending + ts.counts.running + ts.counts.awaiting_input, 0
);
return { by_type: filteredByType, total_tasks: totalTasks, active_tasks: activeTasks };
}
get isEmpty(): boolean {
return this.tasks.length === 0 && this.screenState === 'ready';
}
get isFilteredEmpty(): boolean {
return this.filteredTasks.length === 0 && this.hasActiveFilters;
}
get hasActiveFilters(): boolean {
return (
this.filters.task_types.length > 0 ||
this.filters.statuses.length > 0 ||
this.filters.search.trim() !== '' ||
this.filters.time_range !== 'all'
);
}
get totalPages(): number {
return Math.max(1, Math.ceil(this.totalItems / this.pageSize));
}
get hasNextPage(): boolean {
return this._hasNextPage;
}
// === LIFECYCLE ===
async loadInitialData(initialFilters?: Partial<TaskCenterFilter>): Promise<void> {
this.screenState = 'loading';
log('TaskCenter.Model', 'REASON', 'Loading initial data');
if (initialFilters) {
this.filters = { ...DEFAULT_FILTERS, ...initialFilters };
}
try {
// Parallel fetch: summary + settings + reports
const [summaryData, settingsRes, listData] = await Promise.all([
api.fetchApi<any>('/reports/summary'),
api.fetchApi<any>('/settings/reports').catch(() => null), // non-critical
api.fetchApi<any>(`/reports?${this._buildQueryString()}`),
]);
this.summary = summaryData as TaskSummary;
this.tasks = (listData?.items ?? []) as TaskReport[];
this.totalItems = listData?.total ?? 0;
this._hasNextPage = listData?.has_next ?? false;
if (settingsRes) {
this.disabledTypes = (settingsRes as any)?.disabled_task_types ?? [];
}
this.lastUpdated = new Date();
this.screenState = this.summary.total_tasks === 0 ? 'empty' : 'ready';
log('TaskCenter.Model', 'REFLECT', 'Initial data loaded', {
tasks: this.tasks.length, total: this.totalItems,
});
// Connect WebSocket after initial load
this.connectWebSocket();
} catch (e: unknown) {
this.screenState = 'error';
log('TaskCenter.Model', 'EXPLORE', 'Initial load failed', {}, e instanceof Error ? e.message : 'unknown');
addToast('Не удалось загрузить данные задач', 'error');
}
}
async loadReports(): Promise<void> {
log('TaskCenter.Model', 'REASON', 'Loading reports', { page: this.page });
this.screenState = 'loading';
try {
const listData = await api.fetchApi<any>(`/reports?${this._buildQueryString()}`);
this.tasks = (listData?.items ?? []) as TaskReport[];
this.totalItems = listData?.total ?? 0;
this._hasNextPage = listData?.has_next ?? false;
this.lastUpdated = new Date();
this.screenState = this.totalItems === 0 && !this.hasActiveFilters ? 'empty' : 'ready';
log('TaskCenter.Model', 'REFLECT', 'Reports loaded', { tasks: this.tasks.length, total: this.totalItems });
} catch (e: unknown) {
this.screenState = 'error';
log('TaskCenter.Model', 'EXPLORE', 'Load reports failed', {}, e instanceof Error ? e.message : 'unknown');
addToast('Не удалось загрузить данные задач', 'error');
}
}
async loadPage(pageNum: number): Promise<void> {
this.page = pageNum;
await this.loadReports();
}
connectWebSocket(): void {
if (this._ws) this.disconnectWebSocket();
const url = getTaskEventsWsUrl();
this._ws = new WebSocket(url);
this._ws.onopen = () => {
this.wsConnected = true;
this._reconnectAttempt = 0;
if (this.screenState === 'disconnected' || this.screenState === 'reconnecting') {
this.screenState = 'ready';
// Re-sync summary after reconnect (missed events)
this.refreshSummary();
}
log('TaskCenter.Model', 'REFLECT', 'WebSocket connected');
};
this._ws.onmessage = (event: MessageEvent) => this._onWsMessage(event);
this._ws.onclose = (event: CloseEvent) => {
this.wsConnected = false;
if (event.code === 1000) return; // normal close
this.screenState = 'reconnecting';
// After 2s of reconnecting, show disconnected banner
setTimeout(() => {
if (!this.wsConnected && this.screenState === 'reconnecting') {
this.screenState = 'disconnected';
}
}, 2000);
this._scheduleReconnect();
};
this._ws.onerror = () => {
// Error handled by onclose
};
}
disconnectWebSocket(): void {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
if (this._flushTimeout) {
clearTimeout(this._flushTimeout);
this._flushTimeout = null;
}
if (this._ws) {
this._ws.onclose = null; // prevent reconnect trigger
this._ws.close(1000);
this._ws = null;
}
this.wsConnected = false;
}
destroy(): void {
this.disconnectWebSocket();
if (this._urlSyncTimeout) {
clearTimeout(this._urlSyncTimeout);
this._urlSyncTimeout = null;
}
}
// === ACTIONS ===
applyFilter(partial: Partial<TaskCenterFilter>): void {
this.filters = { ...this.filters, ...partial };
this.page = 1;
this._syncFiltersToUrl();
log('TaskCenter.Model', 'REASON', 'Filter applied', { partial });
this.loadReports();
}
clearFilters(): void {
this.filters = { ...DEFAULT_FILTERS };
this.page = 1;
this._syncFiltersToUrl();
log('TaskCenter.Model', 'REFLECT', 'Filters cleared');
this.loadReports();
}
selectTask(taskId: string): void {
this.selectedTaskId = taskId;
const task = this.tasks.find(t => t.task_id === taskId);
const hint = task?.status === 'failed' ? 'last_error' : undefined;
openDrawerForTask(taskId, hint);
log('TaskCenter.Model', 'REASON', 'Task selected', { taskId, scrollToHint: hint });
}
async refreshSummary(): Promise<void> {
try {
const data = await api.fetchApi<any>('/reports/summary');
this.summary = data as TaskSummary;
this.lastUpdated = new Date();
log('TaskCenter.Model', 'REFLECT', 'Summary refreshed');
} catch (e: unknown) {
log('TaskCenter.Model', 'EXPLORE', 'Summary refresh failed', {}, e instanceof Error ? e.message : 'unknown');
}
}
toggleTypeVisibility(type: TaskType): void {
if (this.visibleTypes.includes(type)) {
this.visibleTypes = this.visibleTypes.filter((t) => t !== type);
} else {
this.visibleTypes = [...this.visibleTypes, type];
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.visibleTypes));
}
log('TaskCenter.Model', 'REFLECT', 'Type visibility toggled', { type, visible: this.visibleTypes });
}
// === PRIVATE METHODS ===
private _onWsMessage(event: MessageEvent): void {
try {
const data = JSON.parse(event.data) as TaskStatusEvent;
if (data.type === 'task_status') {
this._eventBuffer.push(data);
if (!this._flushTimeout) {
this._flushTimeout = setTimeout(() => this._flushEventBuffer(), DEBOUNCE_MS);
}
}
} catch { /* ignore malformed messages */ }
}
private _flushEventBuffer(): void {
this._flushTimeout = null;
const count = this._eventBuffer.length;
if (count === 0) return;
log('TaskCenter.Model', 'REASON', 'Flushing WS events', { count });
this._eventBuffer = [];
this.lastUpdated = new Date();
// Refresh summary for accurate aggregated counts (WS events carry raw Task, not normalized TaskReport)
this.refreshSummary();
}
private _scheduleReconnect(): void {
const idx = Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1);
const delay = RECONNECT_DELAYS[idx];
this._reconnectAttempt++;
log('TaskCenter.Model', 'EXPLORE', 'Scheduling reconnect', { attempt: this._reconnectAttempt, delay });
this._reconnectTimer = setTimeout(() => this.connectWebSocket(), delay);
}
private _buildQueryParams(): URLSearchParams {
const p = new URLSearchParams();
if (this.filters.task_types.length > 0) p.set('task_types', this.filters.task_types.join(','));
if (this.filters.statuses.length > 0) p.set('statuses', this.filters.statuses.join(','));
if (this.filters.search.trim()) p.set('search', this.filters.search.trim());
const timeFrom = timeRangeToFrom(this.filters.time_range);
if (timeFrom) p.set('time_from', timeFrom.toISOString());
return p;
}
private _syncFiltersToUrl(): void {
if (this._urlSyncTimeout) clearTimeout(this._urlSyncTimeout);
this._urlSyncTimeout = setTimeout(() => {
if (typeof history === 'undefined') return;
const p = this._buildQueryParams();
if (this.filters.time_range !== 'all') p.set('range', this.filters.time_range);
if (this.filters.sort_by !== 'updated_at') p.set('sort', this.filters.sort_by);
if (this.filters.sort_order !== 'desc') p.set('order', this.filters.sort_order);
if (this.page > 1) p.set('page', String(this.page));
const qs = p.toString();
history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname);
}, URL_SYNC_MS);
}
private _buildQueryString(): string {
const p = this._buildQueryParams();
p.set('page', String(this.page));
p.set('page_size', String(this.pageSize));
p.set('sort_by', this.filters.sort_by);
p.set('sort_order', this.filters.sort_order);
return p.toString();
}
}
// #endregion TaskCenter.Model

View File

@@ -0,0 +1,237 @@
// #region TaskCenterModelTests [C:3] [TYPE Module] [SEMANTICS test,model,task-center]
// @BRIEF L1 unit tests for TaskCenterModel — no DOM render.
// @RELATION BINDS_TO -> [TaskCenterModel]
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mocks
vi.mock("$lib/api.js", () => ({
api: { fetchApi: vi.fn() },
getTaskEventsWsUrl: vi.fn(() => "ws://test/events"),
}));
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({ openDrawerForTask: vi.fn() }));
import { TaskCenterModel } from "../TaskCenterModel.svelte.ts";
import { api } from "$lib/api.js";
// Sample data
const makeTask = (id: string, status: string, taskType = "llm_verification") => ({
report_id: id,
task_id: id,
task_type: taskType,
status,
summary: `Task ${id}`,
updated_at: "2026-07-02T12:00:00Z",
started_at: null,
details: null,
error_context: null,
});
const makeSummary = (overrides = {}) => ({
by_type: [
{
task_type: "llm_verification",
display_label: "llm_verification",
icon_token: "",
counts: { pending: 1, running: 2, awaiting_input: 0, success: 10, failed: 3 },
total: 16,
},
],
total_tasks: 16,
active_tasks: 3,
...overrides,
});
// #region TaskCenterModel.InvariantTests
describe("TaskCenterModel — L1 invariants", () => {
let model: TaskCenterModel;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Default mock: fetchApi returns empty data for initial load
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
model = new TaskCenterModel();
});
afterEach(() => {
vi.useRealTimers();
});
// #region TaskCenterModel.InitialState
it("starts in loading state with default filters", () => {
expect(model.screenState).toBe("loading");
expect(model.page).toBe(1);
expect(model.pageSize).toBe(20);
expect(model.filters).toEqual({
task_types: [],
statuses: [],
search: '',
time_range: 'all',
sort_by: 'updated_at',
sort_order: 'desc',
});
expect(model.wsConnected).toBe(false);
expect(model.tasks).toEqual([]);
expect(model.summary).toBeNull();
});
// #endregion
// #region TaskCenterModel.FilteredTasks.OrdersActiveFirst
it("active tasks sorted before completed in filteredTasks", () => {
model.tasks = [
makeTask("t3", "success"),
makeTask("t1", "in_progress"),
makeTask("t2", "failed"),
makeTask("t4", "in_progress"),
];
const ft = model.filteredTasks;
// First two should be in_progress (active), then success/failed
expect(ft[0].status).toBe("in_progress");
expect(ft[1].status).toBe("in_progress");
expect(ft[2].status).toBe("success");
expect(ft[3].status).toBe("failed");
});
// #endregion
// #region TaskCenterModel.EmptySummaryShowsZeros
it("visibleSummary returns zeros when summary is null", () => {
expect(model.visibleSummary).toBeNull();
model.summary = makeSummary();
expect(model.visibleSummary).not.toBeNull();
expect(model.visibleSummary!.total_tasks).toBe(16);
});
// #endregion
// #region TaskCenterModel.DisconnectedState
it("screenState transitions to disconnected after abnormal WS close", () => {
// Mock WebSocket with EventTarget-like interface
let wsOnclose: ((e: any) => void) | null = null;
class FakeWS {
close = vi.fn();
set onclose(fn: ((e: any) => void) | null) { wsOnclose = fn; }
get onclose() { return wsOnclose; }
set onopen(_fn: any) { /* noop — not needed for this test */ }
get onopen() { return null; }
set onerror(_fn: any) {}
get onerror() { return null; }
set onmessage(_fn: any) {}
get onmessage() { return null; }
}
global.WebSocket = FakeWS as any;
model.connectWebSocket();
// Simulate abnormal close
wsOnclose?.({ code: 1006 } as CloseEvent);
expect(model.screenState).toBe("reconnecting");
// After 2s timeout, should transition to disconnected
vi.advanceTimersByTime(2100);
expect(model.screenState).toBe("disconnected");
});
// #endregion
// #region TaskCenterModel.ToggleVisibility
it("toggleTypeVisibility adds/removes type from visibleTypes", () => {
model.visibleTypes = ["llm_verification", "backup"];
model.toggleTypeVisibility("llm_verification");
expect(model.visibleTypes).toEqual(["backup"]);
model.toggleTypeVisibility("llm_verification");
expect(model.visibleTypes).toEqual(["backup", "llm_verification"]);
});
// #endregion
// #region TaskCenterModel.ApplyFilterResetsPage
it("applyFilter resets page to 1 and triggers loadReports", async () => {
model.page = 5;
model.tasks = [makeTask("t1", "success")];
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
await model.applyFilter({ task_types: ["backup"] });
expect(model.filters.task_types).toEqual(["backup"]);
expect(model.page).toBe(1);
// Should have called fetchApi to reload
expect(api.fetchApi).toHaveBeenCalled();
});
// #endregion
// #region TaskCenterModel.ClearFilters
it("clearFilters resets filters to defaults and reloads", async () => {
model.filters = { task_types: ["backup"], statuses: ["failed"], search: "test", time_range: "24h", sort_by: "status", sort_order: "asc" };
model.page = 3;
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
await model.clearFilters();
expect(model.filters).toEqual({
task_types: [], statuses: [], search: '', time_range: 'all', sort_by: 'updated_at', sort_order: 'desc',
});
expect(model.page).toBe(1);
expect(api.fetchApi).toHaveBeenCalled();
});
// #endregion
// #region TaskCenterModel.SelectTask
it("selectTask sets selectedTaskId and opens drawer", async () => {
const { openDrawerForTask } = await import("$lib/stores/taskDrawer.svelte.js");
model.selectTask("task-123");
expect(model.selectedTaskId).toBe("task-123");
expect(openDrawerForTask).toHaveBeenCalledWith("task-123", undefined);
// Test with failed task
model.tasks = [makeTask("task-456", "failed")];
model.selectTask("task-456");
expect(openDrawerForTask).toHaveBeenCalledWith("task-456", "last_error");
});
// #endregion
// #region TaskCenterModel.hasNextPage
it("hasNextPage and totalPages derived correctly", () => {
model.totalItems = 45;
model.pageSize = 20;
expect(model.totalPages).toBe(3); // ceil(45/20) = 3
expect(model.hasNextPage).toBe(false); // _hasNextPage not set
model["_hasNextPage"] = true; // simulate API response
expect(model.hasNextPage).toBe(true);
});
// #endregion
});
// #endregion
// #region TaskCenterModel.FilterTests
describe("TaskCenterModel — filter invariants", () => {
let model: TaskCenterModel;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.fetchApi).mockResolvedValue({ items: [], total: 0, has_next: false });
model = new TaskCenterModel();
});
// #region TaskCenterModel.HasActiveFilters
it("hasActiveFilters returns true when filters are active", () => {
expect(model.hasActiveFilters).toBe(false);
model.filters.task_types = ["backup"];
expect(model.hasActiveFilters).toBe(true);
model.filters = { task_types: [], statuses: [], search: '', time_range: 'all', sort_by: 'updated_at', sort_order: 'desc' };
expect(model.hasActiveFilters).toBe(false);
model.filters.search = "test";
expect(model.hasActiveFilters).toBe(true);
});
// #endregion
// #region TaskCenterModel.IsFilteredEmpty
it("isFilteredEmpty checks tasks length and active filters", () => {
expect(model.isFilteredEmpty).toBe(false); // no active filters
model.filters.statuses = ["failed"];
expect(model.isFilteredEmpty).toBe(true); // tasks empty + filters active
model.tasks = [makeTask("t1", "failed")];
expect(model.isFilteredEmpty).toBe(false); // tasks exist
});
// #endregion
});
// #endregion
// #endregion TaskCenterModelTests

View File

@@ -20,6 +20,7 @@ interface ResourceTaskMap {
interface TaskDrawerState {
isOpen: boolean;
activeTaskId: string | null;
scrollToHint: string | null;
resourceTaskMap: ResourceTaskMap;
}
@@ -37,6 +38,7 @@ let autoOpenTaskDrawerPreference: boolean = readAutoOpenTaskDrawerPreference();
const initialState: TaskDrawerState = {
isOpen: false,
activeTaskId: null,
scrollToHint: null,
resourceTaskMap: {},
};
@@ -72,12 +74,12 @@ export const taskDrawerStore = {
},
};
export function openDrawerForTask(taskId: string): boolean {
export function openDrawerForTask(taskId: string, scrollToHint?: string): boolean {
if (!taskId) {
return false;
}
if (_state.isOpen && _state.activeTaskId === taskId) return true; // Already open for this task
_state = { ..._state, isOpen: true, activeTaskId: taskId };
_state = { ..._state, isOpen: true, activeTaskId: taskId, scrollToHint: scrollToHint ?? null };
_notify();
return true;
}

View File

@@ -14,6 +14,7 @@
page = $bindable(0),
pageSize = 20,
total = 0,
showingText = '',
pageSizeOptions = [] as number[],
onPageSizeChange,
class: className = "",
@@ -49,7 +50,7 @@
<!-- Summary -->
<div class="text-sm text-text-muted">
{#if total > 0}
{($t.dashboard?.showing ?? "Showing {start}-{end} of {total}")
{(showingText || $t.dashboard?.showing || "Showing {start}-{end} of {total}")
.replace("{start}", String(start))
.replace("{end}", String(end))
.replace("{total}", String(total))}
@@ -69,7 +70,7 @@
if (onPageSizeChange) onPageSizeChange(val);
}}
>
{#each pageSizeOptions as opt}
{#each pageSizeOptions as opt (opt)}
<option value={opt}>{opt}</option>
{/each}
</select>
@@ -78,7 +79,7 @@
<!-- Page number buttons (only when > 1 page) -->
{#if totalPages > 1}
<div class="flex gap-1">
{#each pageNumbers as p}
{#each pageNumbers as p, i (String(p) + "-" + i)}
{#if p === "..."}
<span class="px-2 py-1 text-sm text-text-muted"></span>
{:else}

View File

@@ -109,4 +109,24 @@ export function formatFileSize(bytes: number | null | undefined): string {
}
// #endregion formatFileSize:Function
// #region formatDuration:Function [TYPE Function]
// @BRIEF Format duration between two dates as human-readable string (e.g. "2m 30s", "1h 15m").
export function formatDuration(start: string | Date | null | undefined, end: string | Date | null | undefined): string {
if (!start || !end) return "";
const startDate = parseDateUTC(start);
const endDate = parseDateUTC(end);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return "";
const diffMs = endDate.getTime() - startDate.getTime();
if (diffMs < 0) return "";
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s`;
const diffMin = Math.floor(diffSec / 60);
const sec = diffSec % 60;
if (diffMin < 60) return sec > 0 ? `${diffMin}m ${sec}s` : `${diffMin}m`;
const diffHour = Math.floor(diffMin / 60);
const min = diffMin % 60;
return min > 0 ? `${diffHour}h ${min}m` : `${diffHour}h`;
}
// #endregion formatDuration:Function
// #endregion Utils.DateFormat

View File

@@ -1,207 +1,219 @@
<!-- #region UnifiedReportsPage [C:3] [TYPE Page] [SEMANTICS sveltekit, report, filter, task, detail] -->
<!-- @ingroup Routes -->
<!-- @BRIEF Unified reports page with task-type and status filtering, list selection, detail panel, and resilient UX states for mixed task types. -->
<!-- #region TaskCenterReportsPage [C:4] [TYPE Page] [SEMANTICS page, reports, task-center, layout, task-status-center] -->
<!-- @BRIEF Task Status Center page — thin render layer over TaskCenterModel. -->
<!-- @LAYER Page -->
<!-- @RELATION DEPENDS_ON -> [ReportsApi] -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:ReportsList] -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:ReportDetailPanel] -->
<!-- @RELATION DEPENDS_ON -> [PageHeader] -->
<!-- @UX_STATE Loading -> Skeleton block visible; filters remain interactive. -->
<!-- @UX_STATE Ready -> Reports list rendered with selected report detail. -->
<!-- @UX_STATE NoData -> Friendly empty state for total=0 without filters. -->
<!-- @UX_STATE FilteredEmpty -> Filtered empty state with one-click clear. -->
<!-- @UX_STATE Error -> Inline error with retry preserving filter selections. -->
<!-- @UX_FEEDBACK Filter change immediately reloads the list. -->
<!-- @UX_FEEDBACK Selecting a report loads its detail in the right panel. -->
<!-- @UX_RECOVERY Retry and clear-filters actions available. -->
<!-- @UX_STATE loading: full-page skeleton -->
<!-- @UX_STATE empty: "No tasks" empty state with navigation hints -->
<!-- @UX_STATE ready: summary panel + filter bar + task list -->
<!-- @UX_STATE disconnected: data visible with stale indicator + reconnect banner -->
<!-- @UX_STATE error: error banner + retry button -->
<!-- @UX_REACTIVITY Model: TaskCenterModel instance. Page is thin render layer. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
<!-- @RELATION BINDS_TO -> [SummaryPanel] -->
<!-- @RELATION BINDS_TO -> [FilterBar] -->
<!-- @RELATION BINDS_TO -> [TaskList] -->
<!-- @RELATION CALLS -> [TaskDrawer] -->
<!-- @PRE User is authenticated (enforced by root +layout.svelte). -->
<!-- @POST Page renders summary, filters, and task list. WebSocket connects automatically. -->
<!-- @SIDE_EFFECT Initializes TaskCenterModel on mount. Connects WebSocket. Destroys on unmount. -->
<script lang="ts">
import { onMount } from "svelte";
import { t } from "$lib/i18n/index.svelte.js";
import { PageHeader } from "$lib/ui";
import { getReports, getReportDetail } from "$lib/api/reports.js";
import ReportsList from "$lib/components/reports/ReportsList.svelte";
import ReportDetailPanel from "$lib/components/reports/ReportDetailPanel.svelte";
import { onMount } from 'svelte';
import { TaskCenterModel } from '$lib/models/TaskCenterModel.svelte.ts';
import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte';
import ReportsList from '$lib/components/reports/ReportsList.svelte';
import { PageHeader, Button, EmptyState, Pagination, Input, Select } from '$lib/ui';
import type { PageData } from './+page.ts';
import type { TaskCenterFilter } from '$types/reports';
let loading = true;
let error = "";
let collection = null;
let selectedReport = null;
let selectedReportDetail = null;
let taskType = "all";
let status = "all";
let page = 1;
const pageSize = 20;
const TASK_TYPE_OPTIONS = [
{ value: "all", label: $t.reports?.all_types },
{ value: "llm_verification", label: "LLM" },
{ value: "backup", label: $t.nav?.backups },
{ value: "migration", label: $t.nav?.migration },
{ value: "documentation", label: "Documentation" },
];
const STATUS_OPTIONS = [
{ value: "all", label: $t.reports?.all_statuses },
{ value: "success", label: "Success" },
{ value: "failed", label: "Failed" },
{ value: "in_progress", label: "In progress" },
{ value: "partial", label: "Partial" },
];
function buildQuery() {
return {
page,
page_size: pageSize,
task_types: taskType === "all" ? [] : [taskType],
statuses: status === "all" ? [] : [status],
sort_by: "updated_at",
sort_order: "desc",
};
}
async function loadReports({ silent = false } = {}) {
try {
if (!silent) loading = true;
error = "";
collection = await getReports(buildQuery());
if (!selectedReport && collection?.items?.length) {
selectedReport = collection.items[0];
selectedReportDetail = await getReportDetail(selectedReport.report_id);
}
} catch (e) {
error = e?.message || "Failed to load reports";
collection = null;
} finally {
if (!silent) loading = false;
}
}
function hasActiveFilters() {
return taskType !== "all" || status !== "all";
}
function clearFilters() {
taskType = "all";
status = "all";
page = 1;
selectedReport = null;
selectedReportDetail = null;
loadReports();
}
function onFilterChange() {
page = 1;
selectedReport = null;
selectedReportDetail = null;
loadReports();
}
async function onSelectReport(event) {
const payload = event?.report ?? event?.detail?.report;
if (!payload) return;
selectedReport = payload;
selectedReportDetail = await getReportDetail(selectedReport.report_id);
}
let { data }: { data: PageData } = $props();
const m = new TaskCenterModel();
onMount(() => {
loadReports();
m.loadInitialData(data.initialFilters);
});
$effect(() => {
return () => m.destroy();
});
// ── Pagination bridge (0-indexed ↔ 1-indexed) ──
let pageZero = $state(m.page - 1);
$effect(() => {
if (pageZero + 1 !== m.page) {
m.loadPage(pageZero + 1);
}
});
$effect(() => {
if (m.page - 1 !== pageZero) {
pageZero = m.page - 1;
}
});
// ── Search debounce ──
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function handleSearchInput(e: Event & { currentTarget: HTMLInputElement }): void {
clearTimeout(searchTimeout);
const value = e.currentTarget.value;
searchTimeout = setTimeout(() => {
m.applyFilter({ search: value });
}, 350);
}
function handleSortChange(e: Event & { currentTarget: HTMLSelectElement }): void {
m.applyFilter({ sort_by: e.currentTarget.value as TaskCenterFilter['sort_by'] });
}
function handleTimeRangeChange(e: Event & { currentTarget: HTMLSelectElement }): void {
m.applyFilter({ time_range: e.currentTarget.value as TaskCenterFilter['time_range'] });
}
</script>
<div class="mx-auto w-full max-w-7xl space-y-4">
<PageHeader
title={$t.reports?.title}
subtitle={() => null}
actions={() => null}
/>
<div class="rounded-xl border border-border bg-surface-card p-4 shadow-sm">
<div class="grid grid-cols-1 gap-2 md:grid-cols-4">
<select
bind:value={taskType}
onchange={onFilterChange}
class="rounded-md border border-border-strong px-2 py-1.5 text-sm"
>
{#each TASK_TYPE_OPTIONS as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<select
bind:value={status}
onchange={onFilterChange}
class="rounded-md border border-border-strong px-2 py-1.5 text-sm"
>
{#each STATUS_OPTIONS as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<button
class="inline-flex items-center justify-center rounded-lg border border-border-strong px-3 py-1.5 text-sm font-medium text-text transition-colors hover:bg-surface-page"
onclick={() => loadReports()}
>
{$t.common?.refresh}
</button>
<button
class="inline-flex items-center justify-center rounded-lg border border-border-strong px-3 py-1.5 text-sm font-medium text-text transition-colors hover:bg-surface-page"
onclick={clearFilters}
>
{$t.reports?.clear_filters}
</button>
<div class="max-w-7xl mx-auto px-4 py-6 space-y-4">
<PageHeader title="Центр статусов">
<div class="flex items-center gap-2">
{#if m.wsConnected}
<span class="w-3 h-3 rounded-full bg-success" aria-label="Соединение установлено"></span>
{:else}
<span class="w-3 h-3 rounded-full {m.screenState === 'reconnecting' ? 'bg-warning animate-pulse' : 'bg-destructive'}"
aria-label="{m.screenState === 'reconnecting' ? 'Переподключение' : 'Соединение потеряно'}"></span>
{/if}
{#if m.lastUpdated}
<span class="text-xs text-text-muted">
Обновлено: {Math.round((Date.now() - m.lastUpdated.getTime()) / 60000)} мин назад
</span>
{/if}
<Button variant="ghost" onclick={() => m.refreshSummary()}>Обновить</Button>
</div>
</div>
</PageHeader>
{#if loading}
<div
class="rounded-xl border border-border bg-surface-card p-6 text-sm text-text-muted shadow-sm"
>
{$t.reports?.loading}
</div>
{:else if error}
<div
class="rounded-xl border border-destructive-light bg-destructive-light p-4 text-destructive shadow-sm"
>
<p>{error}</p>
<button
class="mt-2 inline-flex items-center justify-center rounded-lg border border-destructive-ring px-3 py-1 text-sm font-medium text-destructive transition-colors hover:bg-destructive-light"
onclick={() => loadReports()}
>
{$t.reports?.retry_load || $t.common?.retry}
</button>
</div>
{:else if !collection || collection.total === 0}
<div
class="rounded-xl border border-border bg-surface-card p-6 text-sm text-text-muted shadow-sm"
>
{$t.reports?.empty}
</div>
{:else if collection.items.length === 0 && hasActiveFilters()}
<div
class="rounded-xl border border-border bg-surface-card p-6 text-sm text-text-muted shadow-sm"
>
<p>{$t.reports?.filtered_empty}</p>
<button
class="mt-2 inline-flex items-center justify-center rounded-lg border border-border-strong px-3 py-1 text-sm font-medium text-text transition-colors hover:bg-surface-page"
onclick={clearFilters}
>
{$t.reports?.clear_filters}
</button>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="lg:col-span-2">
<ReportsList
reports={collection?.items || []}
selectedReportId={selectedReport?.report_id}
onselect={onSelectReport}
/>
</div>
<ReportDetailPanel detail={selectedReportDetail} />
{#if m.screenState === 'error'}
<div class="bg-destructive-light border border-destructive-ring text-destructive p-3 rounded-lg flex items-center justify-between">
<span>Не удалось загрузить данные задач. Сервер временно недоступен.</span>
<Button variant="destructive" onclick={() => m.loadInitialData()}>Повторить</Button>
</div>
{/if}
</div>
<!-- #endregion UnifiedReportsPage -->
{#if m.screenState === 'disconnected'}
<div class="bg-warning-light border border-warning-ring text-warning p-3 rounded-lg flex items-center justify-between">
<span>Соединение потеряно. Попытка переподключения...</span>
<Button variant="secondary" onclick={() => m.connectWebSocket()}>Подключиться сейчас</Button>
</div>
{/if}
<!-- Summary Panel -->
<SummaryPanel
summary={m.visibleSummary}
screenState={m.screenState}
onFilterByTypeAndStatus={(type, status) => m.applyFilter({ task_types: [type], statuses: [status] })}
onReconnect={() => m.connectWebSocket()}
activeFilters={{ task_types: m.filters.task_types, statuses: m.filters.statuses }}
/>
{#if m.screenState === 'ready' || m.screenState === 'disconnected'}
<div class="flex flex-wrap gap-2">
<button
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('failed') ? 'border-destructive-ring bg-destructive-light text-destructive' : 'border-border text-text-muted hover:border-destructive-ring hover:text-destructive'}"
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('failed') ? [] : ['failed'] })}
>
Упавшие {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.failed, 0)}
</button>
<button
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('in_progress') ? 'border-primary-ring bg-primary/10 text-primary' : 'border-border text-text-muted hover:border-primary-ring hover:text-primary'}"
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('in_progress') ? [] : ['in_progress'] })}
>
В работе {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.running, 0)}
</button>
<button
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('success') ? 'border-success-ring bg-success-light text-success' : 'border-border text-text-muted hover:border-success-ring hover:text-success'}"
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('success') ? [] : ['success'] })}
>
Успешные {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.success, 0)}
</button>
</div>
{/if}
<!-- Filter bar -->
<div class="bg-surface-muted rounded-lg p-3 flex flex-wrap gap-3 items-center">
<Input
type="search"
placeholder="Поиск по задачам..."
value={m.filters.search}
oninput={handleSearchInput}
class="w-48"
/>
<div class="flex flex-col gap-1">
<label class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Сортировка</label>
<Select
id="sort-select"
value={m.filters.sort_by}
onchange={handleSortChange}
class="w-36"
>
<option value="updated_at">По обновлению</option>
<option value="created_at">По созданию</option>
<option value="status">По статусу</option>
<option value="task_type">По типу</option>
</Select>
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Период</label>
<Select
id="time-range-select"
value={m.filters.time_range}
onchange={handleTimeRangeChange}
class="w-32"
>
<option value="all">Всё время</option>
<option value="1h">Час</option>
<option value="24h">24 часа</option>
<option value="7d">7 дней</option>
<option value="30d">30 дней</option>
</Select>
</div>
<span class="text-xs text-text-muted ml-auto">{m.filteredTasks.length} задач(-и)</span>
{#if m.hasActiveFilters}
<Button variant="ghost" onclick={() => m.clearFilters()}>Сбросить фильтры</Button>
{/if}
</div>
<!-- Task list (basic for US1) -->
<div class="bg-surface-card border border-border rounded-lg p-4">
{#if m.screenState === 'loading'}
<div class="space-y-3">
{#each [1, 2, 3, 4, 5] as n (n)}
<div class="bg-surface-muted rounded h-12 animate-pulse"></div>
{/each}
</div>
{:else if m.isEmpty}
<EmptyState
title="Нет активных задач"
description="В системе нет задач для отображения."
/>
{:else if m.isFilteredEmpty}
<EmptyState
title="Нет задач по выбранным фильтрам"
description="Попробуйте изменить условия фильтрации."
actionLabel="Сбросить фильтры"
onAction={() => m.clearFilters()}
/>
{:else}
<ReportsList
reports={m.filteredTasks}
selectedReportId={m.selectedTaskId}
onselect={(e) => m.selectTask(e.report.task_id || e.report.report_id)}
/>
{#if m.totalPages > 1}
<div class="mt-4">
<Pagination
bind:page={pageZero}
pageSize={m.pageSize}
total={m.totalItems}
showingText={'Показано с {start} по {end} из {total} задач'}
/>
</div>
{/if}
{/if}
</div>
</div>
<!-- #endregion TaskCenterReportsPage -->

View File

@@ -0,0 +1,45 @@
// #region TaskCenterPageLoad [C:2] [TYPE Module] [SEMANTICS load, page, reports, task-status-center]
// @BRIEF Load function for /reports — parses initial filters from URL query params.
// @POST Returns initialFilters for TaskCenterModel.
import type { TaskCenterFilter, TaskType, ReportStatus } from '$types/reports';
export interface PageData {
initialFilters: Partial<TaskCenterFilter>;
}
export function load({ url }: { url: URL }): PageData {
const initialFilters: Partial<TaskCenterFilter> = {};
const typeParam = url.searchParams.get('task_types');
if (typeParam) {
initialFilters.task_types = typeParam.split(',').filter(Boolean) as TaskType[];
}
const statusParam = url.searchParams.get('statuses');
if (statusParam) {
initialFilters.statuses = statusParam.split(',').filter(Boolean) as ReportStatus[];
}
const search = url.searchParams.get('search');
if (search) {
initialFilters.search = search;
}
const range = url.searchParams.get('range');
if (range && ['1h', '24h', '7d', '30d'].includes(range)) {
initialFilters.time_range = range as TaskCenterFilter['time_range'];
}
const sort = url.searchParams.get('sort');
if (sort && ['updated_at', 'created_at', 'status', 'task_type'].includes(sort)) {
initialFilters.sort_by = sort as TaskCenterFilter['sort_by'];
}
const order = url.searchParams.get('order');
if (order && ['asc', 'desc'].includes(order)) {
initialFilters.sort_order = order as TaskCenterFilter['sort_order'];
}
return { initialFilters };
}
// #endregion TaskCenterPageLoad

View File

@@ -0,0 +1,131 @@
// #region ReportsTypes [C:2] [TYPE Module] [SEMANTICS types,dto,reports,task-status-center]
// @BRIEF TypeScript DTOs matching backend Pydantic schemas for the Task Status Center.
// @DATA_CONTRACT Input: TypeScript interfaces → Output: typed API responses
// @RELATION DEPENDS_ON -> [ReportModels] via @DATA_CONTRACT cross-stack
// @RATIONALE Typed API responses prevent field mismatch bugs and enable IDE autocomplete.
/** Canonical task types matching backend TaskType enum. */
export type TaskType =
| 'llm_verification'
| 'backup'
| 'migration'
| 'documentation'
| 'clean_release'
| 'unknown';
/** Normalized report status matching backend ReportStatus enum. */
export type ReportStatus = 'success' | 'failed' | 'in_progress' | 'partial';
/** UI screen states for the Task Center page. */
export type ScreenState = 'loading' | 'ready' | 'empty' | 'disconnected' | 'reconnecting' | 'error';
/** Error/recovery context for failed tasks. */
export interface ErrorContext {
code: string | null;
message: string;
next_actions: string[];
}
/** Single task report — matches backend TaskReport. */
export interface TaskReport {
report_id: string;
task_id: string;
task_type: TaskType;
status: ReportStatus;
started_at: string | null;
updated_at: string;
summary: string;
details?: Record<string, unknown> | null;
validation_record?: Record<string, unknown> | null;
error_context: ErrorContext | null;
source_ref?: Record<string, unknown> | null;
}
/** Query parameters for report list filtering — matches backend ReportQuery. */
export interface ReportQuery {
page: number;
page_size: number;
task_types: TaskType[];
statuses: ReportStatus[];
time_from?: string | null;
time_to?: string | null;
search?: string | null;
sort_by: 'updated_at' | 'created_at' | 'status' | 'task_type';
sort_order: 'asc' | 'desc';
}
/** Paginated collection — matches backend ReportCollection. */
export interface ReportCollection {
items: TaskReport[];
total: number;
page: number;
page_size: number;
has_next: boolean;
applied_filters: ReportQuery;
}
/** Detailed view — matches backend ReportDetailView. */
export interface ReportDetailView {
report: TaskReport;
timeline: Record<string, unknown>[];
diagnostics: Record<string, unknown> | null;
next_actions: string[];
}
/** Status counts for one task type in the summary. */
export interface StatusCounts {
pending: number;
running: number;
awaiting_input: number;
success: number;
failed: number;
}
/** Per-type summary entry. */
export interface TaskTypeSummary {
task_type: TaskType;
display_label: string;
icon_token: string;
counts: StatusCounts;
total: number;
}
/** Full summary response — matches backend TaskSummary. */
export interface TaskSummary {
by_type: TaskTypeSummary[];
total_tasks: number;
active_tasks: number;
}
/** WebSocket task status event from /ws/task-events. */
export interface TaskStatusEvent {
type: 'task_status';
task_id: string;
task: {
id: string;
plugin_id: string;
status: string;
started_at: string | null;
finished_at: string | null;
user_id: string | null;
params: Record<string, unknown> | null;
result: Record<string, unknown> | null;
};
}
/** Filter state for the Task Center UI (broader than ReportQuery). */
export interface TaskCenterFilter {
task_types: TaskType[];
statuses: ReportStatus[];
search: string;
time_range: '1h' | '24h' | '7d' | '30d' | 'all';
sort_by: 'updated_at' | 'created_at' | 'status' | 'task_type';
sort_order: 'asc' | 'desc';
}
/** Reports settings (admin policy for group visibility). */
export interface ReportsSettings {
disabled_task_types: TaskType[];
}
// #endregion ReportsTypes

View File

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

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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