feat(reports): Task Status Center — unified /reports dashboard
Страница /reports трансформирована в Центр статусов задач:
Backend:
- GET /api/reports/summary — агрегированные счётчики тип×статус (5 корзин)
- GET/PUT /api/settings/reports — глобальные настройки отчётов
- _filter_tasks_by_rbac() — row-level фильтрация по роли
- normalize_task_report: LLM-валидация с ошибками → FAILED/PARTIAL
- get_summary(): 5 корзин pending/running/awaiting_input/success/failed
Frontend:
- TaskCenterModel.svelte.ts (400 строк) — Screen Model
- SummaryPanel — сводная панель с цветовым кодированием и active filter
- ReportCard — humanized labels, duration, task_id, failed border
- FilterBar — search + sort + time range с label'ами
- Pagination — showingText, уникальные id для select
- Quick views: «Упавшие», «В работе», «Успешные»
- TaskDrawer: scroll-to-error, footer скрыт для terminal, «Н/Д» fix
Тесты: 48 backend + 38 frontend (2521 всего)
Build: ✅ Console errors: 0
This commit is contained in:
@@ -22,6 +22,7 @@ from ...core.logger import belief_scope
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...dependencies import (
|
||||
get_clean_release_repository,
|
||||
get_current_user,
|
||||
get_task_manager,
|
||||
has_permission,
|
||||
)
|
||||
@@ -30,8 +31,10 @@ from ...models.report import (
|
||||
ReportDetailView,
|
||||
ReportQuery,
|
||||
ReportStatus,
|
||||
TaskSummary,
|
||||
TaskType,
|
||||
)
|
||||
from ...schemas.auth import User
|
||||
from ...services.clean_release.repository import CleanReleaseRepository
|
||||
from ...services.reports.report_service import ReportsService
|
||||
|
||||
@@ -105,6 +108,7 @@ async def list_reports(
|
||||
search: str | None = Query(None, max_length=200),
|
||||
sort_by: str = Query("updated_at"),
|
||||
sort_order: str = Query("desc"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
clean_release_repository: CleanReleaseRepository = Depends(
|
||||
get_clean_release_repository
|
||||
@@ -141,12 +145,33 @@ async def list_reports(
|
||||
service = ReportsService(
|
||||
task_manager, clean_release_repository=clean_release_repository
|
||||
)
|
||||
return service.list_reports(query)
|
||||
return service.list_reports(query, current_user=current_user)
|
||||
|
||||
|
||||
# #endregion list_reports
|
||||
|
||||
|
||||
# #region get_task_summary [C:3] [TYPE Function] [SEMANTICS api, reports, summary, task-status-center]
|
||||
# @ingroup Api
|
||||
# @BRIEF Return aggregated task summary by type × status with RBAC filtering.
|
||||
# @RELATION CALLS -> [ReportsService.get_summary]
|
||||
@router.get("/summary", response_model=TaskSummary)
|
||||
async def get_task_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
clean_release_repository: CleanReleaseRepository = Depends(
|
||||
get_clean_release_repository
|
||||
),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("get_task_summary"):
|
||||
service = ReportsService(
|
||||
task_manager, clean_release_repository=clean_release_repository
|
||||
)
|
||||
return service.get_summary(current_user=current_user)
|
||||
# #endregion get_task_summary
|
||||
|
||||
|
||||
# #region get_report_detail [C:2] [TYPE Function]
|
||||
# @ingroup Api
|
||||
# @BRIEF Return one normalized report detail with diagnostics and next actions.
|
||||
|
||||
@@ -895,4 +895,47 @@ async def test_connection(
|
||||
|
||||
# #endregion test_connection
|
||||
|
||||
|
||||
# #region ReportsSettings [C:3] [TYPE Function] [SEMANTICS settings, reports, visibility, admin]
|
||||
# @ingroup Api
|
||||
# @BRIEF Get and update reports settings (admin-disabled task types).
|
||||
# @RELATION DEPENDS_ON -> [ReportsSettings]
|
||||
# @RELATION DEPENDS_ON -> [has_permission]
|
||||
from ...models.report import ReportsSettings as ReportsSettingsSchema
|
||||
from sqlalchemy import select
|
||||
|
||||
@router.get("/reports")
|
||||
async def get_reports_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
"""Get reports settings — which task types are disabled globally."""
|
||||
record = db.query(AppConfigRecord).filter(AppConfigRecord.id == "reports_settings").first()
|
||||
if record:
|
||||
disabled = record.payload.get("disabled_task_types", [])
|
||||
return {"disabled_task_types": disabled}
|
||||
return {"disabled_task_types": []}
|
||||
|
||||
|
||||
@router.put("/settings/reports")
|
||||
async def update_reports_settings(
|
||||
settings: ReportsSettingsSchema,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(has_permission("settings", "WRITE")),
|
||||
):
|
||||
"""Update reports settings — admin only. Sets which task types are disabled globally."""
|
||||
record = db.query(AppConfigRecord).filter(AppConfigRecord.id == "reports_settings").first()
|
||||
if record:
|
||||
record.payload = {"disabled_task_types": settings.disabled_task_types}
|
||||
else:
|
||||
record = AppConfigRecord(
|
||||
id="reports_settings",
|
||||
payload={"disabled_task_types": settings.disabled_task_types},
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
return {"disabled_task_types": settings.disabled_task_types}
|
||||
# #endregion ReportsSettings
|
||||
|
||||
|
||||
# #endregion SettingsRouter
|
||||
|
||||
@@ -172,7 +172,7 @@ class ReportQuery(BaseModel):
|
||||
@field_validator("sort_by")
|
||||
@classmethod
|
||||
def _validate_sort_by(cls, value: str) -> str:
|
||||
allowed = {"updated_at", "status", "task_type"}
|
||||
allowed = {"updated_at", "created_at", "status", "task_type"}
|
||||
if value not in allowed:
|
||||
raise ValueError(f"sort_by must be one of: {', '.join(sorted(allowed))}")
|
||||
return value
|
||||
@@ -243,4 +243,48 @@ class ReportDetailView(BaseModel):
|
||||
|
||||
# #endregion ReportDetailView
|
||||
|
||||
|
||||
# #region TaskSummary [C:3] [TYPE Class] [SEMANTICS summary, aggregation, task, status, count]
|
||||
# @defgroup Models Module group.
|
||||
# @BRIEF Aggregated task counts by type and status for the summary dashboard.
|
||||
# @DATA_CONTRACT Output: TaskSummary → frontend interface TaskSummary
|
||||
# @RELATION DEPENDS_ON -> [TaskReport]
|
||||
# @TEST_CONTRACT TaskSummaryModel -> { invariants: ["all counts >= 0", "total == sum of all status counts across all types"] }
|
||||
class StatusCounts(BaseModel):
|
||||
"""Counts per status for a single task type."""
|
||||
pending: int = Field(default=0, ge=0)
|
||||
running: int = Field(default=0, ge=0)
|
||||
awaiting_input: int = Field(default=0, ge=0)
|
||||
success: int = Field(default=0, ge=0)
|
||||
failed: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class TaskTypeSummary(BaseModel):
|
||||
"""Summary for one task type."""
|
||||
task_type: TaskType
|
||||
display_label: str = ""
|
||||
icon_token: str = ""
|
||||
counts: StatusCounts
|
||||
total: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class TaskSummary(BaseModel):
|
||||
"""Full summary response for the summary dashboard."""
|
||||
by_type: list[TaskTypeSummary]
|
||||
total_tasks: int = Field(default=0, ge=0)
|
||||
active_tasks: int = Field(default=0, ge=0) # pending + running + awaiting_input
|
||||
|
||||
|
||||
# #endregion TaskSummary
|
||||
|
||||
|
||||
# #region ReportsSettings [C:2] [TYPE Class] [SEMANTICS settings, reports, admin, visibility]
|
||||
# @defgroup Models Module group.
|
||||
# @BRIEF Admin settings for reports visibility — which task types are disabled globally.
|
||||
class ReportsSettings(BaseModel):
|
||||
"""Admin policy: task types disabled for all users."""
|
||||
disabled_task_types: list[TaskType] = Field(default_factory=list)
|
||||
# #endregion ReportsSettings
|
||||
|
||||
|
||||
# #endregion ReportModels
|
||||
|
||||
@@ -126,6 +126,19 @@ def normalize_task_report(task: Task) -> TaskReport:
|
||||
with belief_scope("normalize_task_report"):
|
||||
task_type = resolve_task_type(task.plugin_id)
|
||||
report_status = status_to_report_status(task.status)
|
||||
|
||||
# LLM validation paradox fix: if task completed successfully but validation found
|
||||
# problems, override to PARTIAL (warning) so the UI doesn't flash green for failures.
|
||||
if report_status == ReportStatus.SUCCESS and task.plugin_id == "llm_dashboard_validation":
|
||||
result = task.result if isinstance(task.result, dict) else {}
|
||||
fail_count = result.get("fail_count", 0)
|
||||
warn_count = result.get("warn_count", 0)
|
||||
raw_status = str(result.get("status", "")).upper()
|
||||
if (isinstance(fail_count, int) and fail_count > 0) or raw_status == "FAIL":
|
||||
report_status = ReportStatus.FAILED
|
||||
elif (isinstance(warn_count, int) and warn_count > 0) or raw_status == "WARN":
|
||||
report_status = ReportStatus.PARTIAL
|
||||
|
||||
profile = get_type_profile(task_type)
|
||||
|
||||
started_at = task.started_at if isinstance(task.started_at, datetime) else None
|
||||
|
||||
@@ -24,11 +24,50 @@ from ...models.report import (
|
||||
ReportDetailView,
|
||||
ReportQuery,
|
||||
ReportStatus,
|
||||
StatusCounts,
|
||||
TaskReport,
|
||||
TaskSummary,
|
||||
TaskType,
|
||||
TaskTypeSummary,
|
||||
)
|
||||
from ..clean_release.repository import CleanReleaseRepository
|
||||
from .normalizer import normalize_task_report
|
||||
from ...core.task_manager.models import Task, TaskStatus
|
||||
|
||||
|
||||
# #region _filter_tasks_by_rbac [C:3] [TYPE Function] [SEMANTICS service, rbac, filter, task-status-center]
|
||||
# @ingroup Services
|
||||
# @BRIEF Filter tasks by user role for RBAC compliance.
|
||||
# @PRE current_user is an authenticated User ORM object.
|
||||
# @POST Returns list of tasks visible to the user.
|
||||
# @RATIONALE Extracted as a pure function for testability — no side effects, no service dependencies.
|
||||
# @REJECTED Inline filtering in list_reports — mixes authorization with query logic, making unit testing harder.
|
||||
def _filter_tasks_by_rbac(tasks: list[Task], current_user) -> list[Task]:
|
||||
"""
|
||||
Filter tasks based on user role.
|
||||
|
||||
Rules (from TSC-FR-009):
|
||||
- admin → all tasks
|
||||
- analyst → own tasks + system tasks (user_id=None)
|
||||
- viewer → own tasks only
|
||||
"""
|
||||
is_admin = any(
|
||||
getattr(role, "is_admin", False) or role.name == "Admin"
|
||||
for role in current_user.roles
|
||||
)
|
||||
if is_admin:
|
||||
return tasks
|
||||
is_analyst = any(
|
||||
role.name == "analyst" for role in current_user.roles
|
||||
if not getattr(role, "is_admin", False)
|
||||
)
|
||||
if is_analyst:
|
||||
return [t for t in tasks if t.user_id == current_user.id or t.user_id is None]
|
||||
# viewer or unknown: own tasks only
|
||||
return [t for t in tasks if t.user_id == current_user.id]
|
||||
|
||||
|
||||
# #endregion _filter_tasks_by_rbac
|
||||
|
||||
|
||||
# #region ReportsService [C:5] [TYPE Class]
|
||||
@@ -190,14 +229,20 @@ class ReportsService:
|
||||
# endregion _sort_reports
|
||||
|
||||
# region list_reports [TYPE Function]
|
||||
# @PURPOSE: Return filtered, sorted, paginated report collection.
|
||||
# @PURPOSE: Return filtered, sorted, paginated report collection with optional RBAC filtering.
|
||||
# @PRE query has passed schema validation.
|
||||
# @POST Returns {items,total,page,page_size,has_next,applied_filters}.
|
||||
# @POST Returns ReportCollection with optional RBAC filtering applied.
|
||||
# @PARAM query (ReportQuery) - List filters and pagination.
|
||||
# @PARAM current_user (optional) - If provided, filters tasks by RBAC role.
|
||||
# @RETURN ReportCollection - Paginated unified reports payload.
|
||||
def list_reports(self, query: ReportQuery) -> ReportCollection:
|
||||
def list_reports(self, query: ReportQuery, current_user=None) -> ReportCollection:
|
||||
with belief_scope("list_reports"):
|
||||
reports = self._load_normalized_reports()
|
||||
if current_user:
|
||||
tasks = self.task_manager.get_all_tasks()
|
||||
filtered_tasks = _filter_tasks_by_rbac(tasks, current_user)
|
||||
task_ids = {t.id for t in filtered_tasks}
|
||||
reports = [r for r in reports if r.task_id in task_ids]
|
||||
filtered = [
|
||||
report for report in reports if self._matches_query(report, query)
|
||||
]
|
||||
@@ -298,6 +343,59 @@ class ReportsService:
|
||||
|
||||
# endregion get_report_detail
|
||||
|
||||
# region get_summary [TYPE Function]
|
||||
# @PURPOSE: Return aggregated task summary by type × status with RBAC filtering.
|
||||
# @PRE current_user is authenticated.
|
||||
# @POST Returns TaskSummary with counts filtered by user role.
|
||||
# @SIDE_EFFECT None (read-only aggregation).
|
||||
def get_summary(self, current_user=None) -> TaskSummary:
|
||||
with belief_scope("get_summary"):
|
||||
tasks = self.task_manager.get_all_tasks()
|
||||
if current_user:
|
||||
tasks = _filter_tasks_by_rbac(tasks, current_user)
|
||||
|
||||
# Group by task_type, using raw TaskStatus for 5 granular buckets
|
||||
by_type: dict[TaskType, dict[str, int]] = {}
|
||||
for task in tasks:
|
||||
report = normalize_task_report(task)
|
||||
tt = report.task_type
|
||||
if tt not in by_type:
|
||||
by_type[tt] = {"pending": 0, "running": 0, "awaiting_input": 0, "success": 0, "failed": 0}
|
||||
|
||||
# Map raw TaskStatus → summary bucket (not normalized ReportStatus)
|
||||
raw = str(task.status.value if hasattr(task, 'status') else "").upper()
|
||||
if raw == TaskStatus.PENDING.value:
|
||||
bucket = "pending"
|
||||
elif raw == TaskStatus.RUNNING.value:
|
||||
bucket = "running"
|
||||
elif raw in {TaskStatus.AWAITING_INPUT.value, TaskStatus.AWAITING_MAPPING.value}:
|
||||
bucket = "awaiting_input"
|
||||
elif raw == TaskStatus.SUCCESS.value:
|
||||
bucket = "success"
|
||||
elif raw == TaskStatus.FAILED.value:
|
||||
bucket = "failed"
|
||||
else:
|
||||
bucket = "pending"
|
||||
by_type[tt][bucket] += 1
|
||||
|
||||
type_summaries = []
|
||||
for tt, counts in by_type.items():
|
||||
total = sum(counts.values())
|
||||
active = counts["pending"] + counts["running"] + counts["awaiting_input"]
|
||||
type_summaries.append(TaskTypeSummary(
|
||||
task_type=tt,
|
||||
display_label=tt.value,
|
||||
icon_token="",
|
||||
counts=StatusCounts(**counts),
|
||||
total=total,
|
||||
))
|
||||
|
||||
total_tasks = sum(s.total for s in type_summaries)
|
||||
active_tasks = sum(s.counts.pending + s.counts.running + s.counts.awaiting_input for s in type_summaries)
|
||||
return TaskSummary(by_type=type_summaries, total_tasks=total_tasks, active_tasks=active_tasks)
|
||||
|
||||
# endregion get_summary
|
||||
|
||||
|
||||
# #endregion ReportsService
|
||||
|
||||
|
||||
Reference in New Issue
Block a user