Optimize dashboard overview loading

This commit is contained in:
2026-06-06 00:42:00 +02:00
parent a412998c96
commit b340489d96
14 changed files with 990 additions and 88 deletions

View File

@@ -1,5 +1,6 @@
import hashlib
import os
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI
@@ -26,26 +27,37 @@ class ETagMiddleware(BaseHTTPMiddleware):
"""Add ETag + conditional-GET (304) support to all JSON GET responses."""
async def dispatch(self, request: Request, call_next):
started = time.perf_counter()
response = await call_next(request)
if request.method != "GET":
response.headers["X-StateHub-Elapsed-Ms"] = f"{(time.perf_counter() - started) * 1000:.1f}"
return response
if "application/json" not in response.headers.get("content-type", ""):
response.headers["X-StateHub-Elapsed-Ms"] = f"{(time.perf_counter() - started) * 1000:.1f}"
return response
body_parts = []
async for chunk in response.body_iterator:
body_parts.append(chunk)
body = b"".join(body_parts)
elapsed_ms = f"{(time.perf_counter() - started) * 1000:.1f}"
etag = '"' + hashlib.md5(body, usedforsecurity=False).hexdigest() + '"'
if request.headers.get("if-none-match") == etag:
return StarletteResponse(
status_code=304,
headers={"ETag": etag, "Cache-Control": "no-cache"},
headers={
"ETag": etag,
"Cache-Control": "no-cache",
"X-StateHub-Elapsed-Ms": elapsed_ms,
"X-StateHub-Response-Bytes": "0",
},
)
headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
headers["ETag"] = etag
headers["X-StateHub-Elapsed-Ms"] = elapsed_ms
headers["X-StateHub-Response-Bytes"] = str(len(body))
if not any(k.lower() == "cache-control" for k in headers):
headers["Cache-Control"] = "no-cache"
return StarletteResponse(
@@ -84,7 +96,7 @@ app.add_middleware(
allow_origins=_cors_origins,
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
allow_headers=["Content-Type", "If-None-Match"],
expose_headers=["ETag"],
expose_headers=["ETag", "X-StateHub-Elapsed-Ms", "X-StateHub-Response-Bytes", "X-StateHub-Cache"],
)
app.include_router(domains.router)

View File

@@ -1,7 +1,7 @@
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,6 +17,7 @@ from api.models.extension_point import ExtensionPoint
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.models.sbom_entry import SBOMEntry
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.task import Task, TaskPriority, TaskStatus
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic, TopicStatus
@@ -26,6 +27,9 @@ from api.schemas.decision import DecisionRead
from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead
from api.schemas.state import (
DashboardOverview,
DashboardSourceMeta,
DashboardWorkplanRow,
DecisionTotals,
NextStep,
StateSummary,
@@ -38,6 +42,7 @@ from api.schemas.task import TaskRead
from api.schemas.topic import TopicRead, TopicWithWorkstreams
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
from api.schemas.workstream_dependency import WorkstreamDepStub
from api.routers.workstreams import _workplan_index
from api.task_status import TERMINAL_TASK_STATUSES, status_value
from api.workplan_status import (
CLOSED_WORKSTREAM_STATUSES,
@@ -51,17 +56,25 @@ router = APIRouter(prefix="/state", tags=["state"])
_SUMMARY_CACHE: StateSummary | None = None
_SUMMARY_CACHE_AT: float = 0.0
_SUMMARY_TTL = 15.0
_OVERVIEW_CACHE: DashboardOverview | None = None
_OVERVIEW_CACHE_AT: float = 0.0
_OVERVIEW_TTL = 10.0
@router.get("/summary", response_model=StateSummary)
async def get_summary(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
) -> StateSummary:
global _SUMMARY_CACHE, _SUMMARY_CACHE_AT
no_cache = "no-cache" in request.headers.get("cache-control", "")
if not no_cache and _SUMMARY_CACHE is not None and (time.monotonic() - _SUMMARY_CACHE_AT) < _SUMMARY_TTL:
response.headers["X-StateHub-Cache"] = "hit"
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
return _SUMMARY_CACHE
response.headers["X-StateHub-Cache"] = "miss"
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
# Run all queries sequentially on one session.
# AsyncSession does not support concurrent operations (no gather on same session).
@@ -362,6 +375,309 @@ async def get_summary(
return result
@router.get("/overview", response_model=DashboardOverview)
async def get_overview(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
) -> DashboardOverview:
"""Bounded dashboard overview read model.
This is intentionally narrower than /state/summary. The dashboard overview
needs counts, recent rows, and chart-ready workplan rows; it does not need
full task or workplan lists transferred to the browser on every poll.
"""
global _OVERVIEW_CACHE, _OVERVIEW_CACHE_AT
no_cache = "no-cache" in request.headers.get("cache-control", "")
if not no_cache and _OVERVIEW_CACHE is not None and (time.monotonic() - _OVERVIEW_CACHE_AT) < _OVERVIEW_TTL:
response.headers["X-StateHub-Cache"] = "hit"
response.headers["Cache-Control"] = "max-age=10, stale-while-revalidate=30"
return _OVERVIEW_CACHE
response.headers["X-StateHub-Cache"] = "miss"
response.headers["Cache-Control"] = "max-age=10, stale-while-revalidate=30"
result = await _build_dashboard_overview(session)
_OVERVIEW_CACHE = result
_OVERVIEW_CACHE_AT = time.monotonic()
return result
async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
topics_rows = await session.execute(
select(Topic)
.options(
selectinload(Topic.domain),
noload(Topic.workstreams),
noload(Topic.decisions),
noload(Topic.progress_events),
)
.where(Topic.status != TopicStatus.archived)
.order_by(Topic.created_at)
)
topics = list(topics_rows.scalars().all())
topic_map = {topic.id: topic for topic in topics}
workstream_rows = await session.execute(
select(Workstream)
.options(noload("*"))
.order_by(
Workstream.planning_priority.asc().nullslast(),
Workstream.planning_order.asc().nullslast(),
Workstream.updated_at.desc(),
)
)
workstreams_all = list(workstream_rows.scalars().all())
topic_workstreams: dict = {t.id: [] for t in topics}
for w in sorted(workstreams_all, key=lambda item: item.created_at):
if w.topic_id not in topic_workstreams:
continue
topic_workstreams[w.topic_id].append({
"id": w.id,
"slug": w.slug,
"title": w.title,
"status": w.status,
"owner": w.owner,
"due_date": w.due_date,
})
repo_rows = await session.execute(
select(ManagedRepo.id, ManagedRepo.slug, Domain.slug)
.join(Domain, Domain.id == ManagedRepo.domain_id)
.order_by(ManagedRepo.slug)
)
repo_map = {
repo_id: {"slug": repo_slug, "domain_slug": domain_slug}
for repo_id, repo_slug, domain_slug in repo_rows
}
task_counts_by_ws: dict = {}
task_statuses_per_ws: dict = {}
task_totals_by_status: dict[str, int] = {}
for ws_id, task_status, count in await session.execute(
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
):
status = status_value(task_status)
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
task_counts_by_ws[ws_id]["total"] += count
if status in {"done", "progress", "wait", "todo"}:
task_counts_by_ws[ws_id][status] += count
task_statuses_per_ws.setdefault(ws_id, []).extend([status] * count)
task_totals_by_status[status] = task_totals_by_status.get(status, 0) + count
open_ws = [
w for w in workstreams_all
if normalize_workstream_status(w.status) in OPEN_WORKSTREAM_STATUSES
]
open_ws_ids = [w.id for w in open_ws]
dep_rows = []
if open_ws_ids:
dep_result = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
)
)
dep_rows = list(dep_result.scalars().all())
ws_lookup = {w.id: w for w in workstreams_all}
workstream_flow = load_flow("workstream")
flow_engine = FlowEngine()
effective_status: dict = {}
for w in open_ws:
flow_obj = {
"status": w.status,
"workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"dependencies": [
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
for d in dep_rows
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
],
}
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workstream_status(w.status)
topic_counts = {r[0]: r[1] for r in await session.execute(
select(Topic.status, func.count()).group_by(Topic.status)
)}
ws_counts = {r[0]: r[1] for r in await session.execute(
select(Workstream.status, func.count()).group_by(Workstream.status)
)}
dec_counts = {r[0]: r[1] for r in await session.execute(
select(Decision.status, func.count()).group_by(Decision.status)
)}
totals = Totals(
topics=TopicTotals(
active=topic_counts.get(TopicStatus.active, 0),
paused=topic_counts.get(TopicStatus.paused, 0),
archived=topic_counts.get(TopicStatus.archived, 0),
total=sum(topic_counts.values()),
),
workstreams=WorkstreamTotals(
proposed=ws_counts.get("proposed", 0),
ready=ws_counts.get("ready", 0) + ws_counts.get("todo", 0),
active=sum(1 for status in effective_status.values() if status == "active"),
blocked=sum(1 for status in effective_status.values() if status == "blocked"),
backlog=ws_counts.get("backlog", 0),
finished=(
ws_counts.get("finished", 0)
+ ws_counts.get("completed", 0)
+ ws_counts.get("accepted", 0)
),
archived=ws_counts.get("archived", 0),
total=sum(ws_counts.values()),
),
tasks=TaskTotals(
wait=task_totals_by_status.get("wait", 0),
todo=task_totals_by_status.get("todo", 0),
progress=task_totals_by_status.get("progress", 0),
done=task_totals_by_status.get("done", 0),
cancel=task_totals_by_status.get("cancel", 0),
total=sum(task_totals_by_status.values()),
),
decisions=DecisionTotals(
open=dec_counts.get(DecisionStatus.open, 0),
resolved=dec_counts.get(DecisionStatus.resolved, 0),
escalated=dec_counts.get(DecisionStatus.escalated, 0),
superseded=dec_counts.get(DecisionStatus.superseded, 0),
total=sum(dec_counts.values()),
),
)
blocking_rows = await session.execute(
select(Decision)
.where(Decision.decision_type == DecisionType.pending)
.where(Decision.status.in_([DecisionStatus.open, DecisionStatus.escalated]))
.order_by(Decision.deadline.asc().nullslast(), Decision.created_at)
)
blocking = list(blocking_rows.scalars().all())
waiting_rows = await session.execute(
select(Task).options(noload("*")).where(Task.status == TaskStatus.wait).order_by(Task.created_at)
)
waiting = list(waiting_rows.scalars().all())
recent_rows = await session.execute(
select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20)
)
recent = list(recent_rows.scalars().all())
milestone_rows = await session.execute(
select(ProgressEvent)
.options(noload("*"))
.where(ProgressEvent.event_type == "milestone")
.where(ProgressEvent.summary.like("Project registered with State Hub:%"))
.order_by(ProgressEvent.created_at.desc())
.limit(500)
)
registration_milestones = list(milestone_rows.scalars().all())
contrib_type_counts = {r[0].value: r[1] for r in await session.execute(
select(Contribution.type, func.count()).group_by(Contribution.type)
)}
contrib_status_counts = {r[0].value: r[1] for r in await session.execute(
select(Contribution.status, func.count()).group_by(Contribution.status)
)}
contribution_counts = {**contrib_type_counts, **contrib_status_counts}
_COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL")
all_direct_prod_rows = await session.execute(
select(SBOMEntry.license_spdx)
.where(SBOMEntry.is_direct.is_(True))
.where(SBOMEntry.is_dev.is_(False))
)
licence_risk_count = sum(
1 for (lic,) in all_direct_prod_rows.all()
if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS)
)
snapshot_count, package_total = (await session.execute(
select(
func.count(SBOMSnapshot.id),
func.coalesce(func.sum(SBOMSnapshot.entry_count), 0),
)
)).one()
open_cap_req_count = (await session.execute(
select(func.count()).select_from(CapabilityRequest).where(
CapabilityRequest.status.in_(["requested", "accepted", "in_progress", "ready_for_review"])
)
)).scalar() or 0
sources: dict[str, DashboardSourceMeta] = {}
try:
workplan_index = await _workplan_index(refresh=False, session=session)
workplan_map = workplan_index.get("workstreams", {})
index_meta = workplan_index.get("_meta", {})
sources["workplan_index"] = DashboardSourceMeta(
ok=not bool(index_meta.get("last_error")),
stale=bool(index_meta.get("stale")),
cache_age_seconds=index_meta.get("cache_age_seconds"),
refresh_in_progress=bool(index_meta.get("refresh_in_progress")),
error=index_meta.get("last_error"),
)
except Exception as exc:
workplan_map = {}
sources["workplan_index"] = DashboardSourceMeta(ok=False, error=str(exc))
workplan_rows: list[DashboardWorkplanRow] = []
for w in workstreams_all:
repo = repo_map.get(w.repo_id)
topic = topic_map.get(w.topic_id)
workplan = workplan_map.get(str(w.id), {})
counts = task_counts_by_ws.get(w.id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
workplan_rows.append(DashboardWorkplanRow(
id=w.id,
title=w.title,
status=normalize_workstream_status(w.status),
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
workplan_filename=workplan.get("filename"),
workplan_relative_path=workplan.get("relative_path"),
workplan_archived=bool(workplan.get("archived", False)),
health_labels=workplan.get("health_labels", []),
href=f"./workstreams/{w.id}",
done=counts.get("done", 0),
progress=counts.get("progress", 0),
wait=counts.get("wait", 0),
todo=counts.get("todo", 0),
total=counts.get("total", 0),
created_at=w.created_at,
updated_at=w.updated_at,
))
return DashboardOverview(
generated_at=datetime.now(tz=timezone.utc),
totals=totals,
topics=[
TopicWithWorkstreams(
**TopicRead.model_validate(t).model_dump(),
workstreams=topic_workstreams.get(t.id, []),
)
for t in topics
],
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking],
waiting_tasks=[TaskRead.model_validate(t) for t in waiting],
blocked_tasks=[TaskRead.model_validate(t) for t in waiting],
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
next_steps=await _derive_next_steps(session),
contribution_counts=contribution_counts,
licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_count,
sbom_snapshot_count=int(snapshot_count or 0),
sbom_package_total=int(package_total or 0),
registration_milestones=[ProgressEventRead.model_validate(e) for e in registration_milestones],
workplan_rows=workplan_rows,
sources=sources,
diagnostics={
"workplan_row_count": len(workplan_rows),
"task_count_strategy": "grouped",
},
)
async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
"""Compute per-domain stats for the state summary."""
domains_rows = await session.execute(

View File

@@ -9,7 +9,7 @@ from api.database import get_session
from api.models.task import Task, TaskStatus
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
from api.schemas.task import TaskCountRead, TaskCreate, TaskRead, TaskUpdate
from api.services.lifecycle import status_value, transition_task_status
from api.task_status import normalize_task_status
@@ -24,6 +24,8 @@ async def list_tasks(
needs_human: bool | None = Query(None),
priority: str | None = None,
due_date_before: date | None = None,
limit: int | None = Query(None, ge=1, le=5000),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
) -> list[Task]:
q = select(Task)
@@ -40,10 +42,32 @@ async def list_tasks(
if due_date_before is not None:
q = q.where(Task.due_date <= due_date_before)
q = q.order_by(Task.created_at)
if offset:
q = q.offset(offset)
if limit is not None:
q = q.limit(limit)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/counts", response_model=list[TaskCountRead])
async def count_tasks(
workstream_id: uuid.UUID | None = None,
status: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[TaskCountRead]:
q = select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
if workstream_id:
q = q.where(Task.workstream_id == workstream_id)
if status:
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
rows = await session.execute(q)
return [
TaskCountRead(workstream_id=ws_id, status=task_status, count=count)
for ws_id, task_status, count in rows
]
@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
async def create_task(
body: TaskCreate,

View File

@@ -3,6 +3,7 @@ import logging
import uuid
import socket
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -40,6 +41,8 @@ workplan_router = APIRouter(prefix="/workplans", tags=["workplans"])
_INDEX_CACHE: dict[str, Any] | None = None
_INDEX_CACHE_AT: float = 0.0
_INDEX_TTL = 30.0
_INDEX_REFRESH_TASK: asyncio.Task | None = None
_INDEX_LAST_ERROR: str | None = None
_LEGACY_OWNER = "state-hub.api"
_COMPLETED_WORKSTREAM_EVENT = "org.statehub.workstream.completed"
@@ -170,16 +173,7 @@ async def _list_workstreams(
return list(result.scalars().all())
async def _workplan_index(
*,
refresh: bool,
session: AsyncSession,
) -> dict[str, Any]:
"""Map file-backed workplan ids to their local workplan filenames."""
global _INDEX_CACHE, _INDEX_CACHE_AT
if not refresh and _INDEX_CACHE is not None and (time.monotonic() - _INDEX_CACHE_AT) < _INDEX_TTL:
return _INDEX_CACHE
async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
result = await session.execute(
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
)
@@ -218,8 +212,78 @@ async def _workplan_index(
"needs_review": bool(review and review.needs_review),
"health_labels": ["needs_review"] if review and review.needs_review else [],
}
_INDEX_CACHE = {"workplans": index, "workstreams": index}
return {"workplans": index, "workstreams": index}
def _index_with_meta(*, stale: bool, refresh_in_progress: bool) -> dict[str, Any]:
age = time.monotonic() - _INDEX_CACHE_AT if _INDEX_CACHE_AT else None
return {
**(_INDEX_CACHE or {"workplans": {}, "workstreams": {}}),
"_meta": {
"generated_at": _INDEX_CACHE.get("_meta", {}).get("generated_at") if _INDEX_CACHE else None,
"stale": stale,
"cache_age_seconds": round(age, 3) if age is not None else None,
"refresh_in_progress": refresh_in_progress,
"last_error": _INDEX_LAST_ERROR,
},
}
async def _refresh_workplan_index_background() -> None:
global _INDEX_CACHE, _INDEX_CACHE_AT, _INDEX_LAST_ERROR
from api.database import async_session_factory
try:
async with async_session_factory() as session:
index = await _build_workplan_index(session)
index["_meta"] = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"stale": False,
"cache_age_seconds": 0.0,
"refresh_in_progress": False,
"last_error": None,
}
_INDEX_CACHE = index
_INDEX_CACHE_AT = time.monotonic()
_INDEX_LAST_ERROR = None
except Exception as exc:
_INDEX_LAST_ERROR = str(exc)
def _ensure_index_refresh_started() -> None:
global _INDEX_REFRESH_TASK
if _INDEX_REFRESH_TASK is not None and not _INDEX_REFRESH_TASK.done():
return
_INDEX_REFRESH_TASK = asyncio.create_task(_refresh_workplan_index_background())
async def _workplan_index(
*,
refresh: bool,
session: AsyncSession,
) -> dict[str, Any]:
"""Map file-backed workplan ids to their local workplan filenames."""
global _INDEX_CACHE, _INDEX_CACHE_AT, _INDEX_LAST_ERROR
cache_age = time.monotonic() - _INDEX_CACHE_AT if _INDEX_CACHE_AT else None
if not refresh and _INDEX_CACHE is not None and cache_age is not None and cache_age < _INDEX_TTL:
refresh_running = _INDEX_REFRESH_TASK is not None and not _INDEX_REFRESH_TASK.done()
return _index_with_meta(stale=False, refresh_in_progress=refresh_running)
if not refresh and _INDEX_CACHE is not None:
_ensure_index_refresh_started()
return _index_with_meta(stale=True, refresh_in_progress=True)
index = await _build_workplan_index(session)
index["_meta"] = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"stale": False,
"cache_age_seconds": 0.0,
"refresh_in_progress": False,
"last_error": None,
}
_INDEX_CACHE = index
_INDEX_CACHE_AT = time.monotonic()
_INDEX_LAST_ERROR = None
return _INDEX_CACHE

View File

@@ -1,5 +1,6 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel
@@ -84,3 +85,51 @@ class StateSummary(BaseModel):
contribution_counts: dict[str, int] = {}
licence_risk_count: int = 0
open_capability_requests: int = 0
class DashboardWorkplanRow(BaseModel):
id: uuid.UUID
title: str
status: str
domain: str = "unknown"
repo_label: str = "unassigned"
workplan_filename: str | None = None
workplan_relative_path: str | None = None
workplan_archived: bool = False
health_labels: list[str] = []
href: str
done: int = 0
progress: int = 0
wait: int = 0
todo: int = 0
total: int = 0
created_at: datetime
updated_at: datetime
class DashboardSourceMeta(BaseModel):
ok: bool = True
stale: bool = False
cache_age_seconds: float | None = None
refresh_in_progress: bool = False
error: str | None = None
class DashboardOverview(BaseModel):
generated_at: datetime
totals: Totals
topics: list[TopicWithWorkstreams]
blocking_decisions: list[DecisionRead]
waiting_tasks: list[TaskRead]
blocked_tasks: list[TaskRead] = []
recent_progress: list[ProgressEventRead]
next_steps: list[NextStep] = []
contribution_counts: dict[str, int] = {}
licence_risk_count: int = 0
open_capability_requests: int = 0
sbom_snapshot_count: int = 0
sbom_package_total: int = 0
registration_milestones: list[ProgressEventRead] = []
workplan_rows: list[DashboardWorkplanRow] = []
sources: dict[str, DashboardSourceMeta] = {}
diagnostics: dict[str, Any] = {}

View File

@@ -93,3 +93,9 @@ class TaskRead(TaskStatusMixin):
parent_task_id: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
class TaskCountRead(TaskStatusMixin):
workstream_id: uuid.UUID
status: TaskStatus
count: int