From 27d9f394e8312467313adcbf00053766e0495eba Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 19:20:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(reports):=20=E6=96=B0=E5=A2=9E=E5=A0=B1?= =?UTF-8?q?=E8=A1=A8=E8=B3=87=E6=96=99=E6=BA=90=E5=81=A5=E5=BA=B7=20read?= =?UTF-8?q?=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/api/v1/agents.py | 27 ++ .../services/ai_agent_report_source_health.py | 388 ++++++++++++++++++ .../test_ai_agent_report_source_health_api.py | 85 ++++ apps/web/messages/en.json | 1 + apps/web/messages/zh-TW.json | 1 + apps/web/src/app/[locale]/reports/page.tsx | 140 ++++++- 6 files changed, 620 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/services/ai_agent_report_source_health.py create mode 100644 apps/api/tests/test_ai_agent_report_source_health_api.py diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 4a5a259a6..e4a72a7cd 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -94,6 +94,9 @@ from src.services.ai_agent_receipt_readback_owner_review import ( from src.services.ai_agent_report_no_write_analysis_runtime import ( load_latest_ai_agent_report_no_write_analysis_runtime, ) +from src.services.ai_agent_report_source_health import ( + build_ai_agent_report_source_health, +) from src.services.ai_agent_low_medium_risk_whitelist import ( load_latest_ai_agent_low_medium_risk_whitelist, ) @@ -1288,6 +1291,30 @@ async def get_agent_report_status_board() -> dict[str, Any]: ) from exc +@router.get( + "/agent-report-source-health", + response_model=dict[str, Any], + summary="取得 AI Agent 報表資料源健康與 no-send preview", + description=( + "回傳日報 / 週報 / 月報資料源健康、全 0 判讀、no-send preview、" + "KM / PlayBook / 腳本 / 排程 / Verifier 沉澱與 report-source-gap 工作項;" + "此端點只做 redacted readback,不送 Telegram、不寫 Gateway queue、不改排程、" + "不啟動 AI runtime、不執行中低風險自動修復、不讀 secret。" + ), +) +async def get_agent_report_source_health() -> dict[str, Any]: + """Return the read-only AI Agent report source health model.""" + try: + payload = await build_ai_agent_report_source_health() + return redact_public_lan_topology(payload) + except Exception as exc: + logger.error("ai_agent_report_source_health_failed", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="AI Agent 報表資料源健康讀取失敗", + ) from exc + + @router.get( "/agent-report-runtime-readiness", response_model=dict[str, Any], diff --git a/apps/api/src/services/ai_agent_report_source_health.py b/apps/api/src/services/ai_agent_report_source_health.py new file mode 100644 index 000000000..cf5725aec --- /dev/null +++ b/apps/api/src/services/ai_agent_report_source_health.py @@ -0,0 +1,388 @@ +""" +AI Agent report source health read model. + +This module builds a redacted, read-only source-health view for daily, weekly, +monthly reports. It intentionally does not send Telegram, write Gateway queues, +enable schedulers, execute AI repair, mutate incidents, or open runtime gates. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from typing import Any, Awaitable, Callable +from zoneinfo import ZoneInfo + +import structlog + +from src.services.ai_agent_report_status_board import load_latest_ai_agent_report_status_board + +logger = structlog.get_logger(__name__) + +_SCHEMA_VERSION = "ai_agent_report_source_health_v1" +_CURRENT_TASK_ID = "P2-109" +_NEXT_TASK_ID = "P2-110" +_RUNTIME_AUTHORITY = "report_source_health_read_model_only_no_send_or_write" +_TAIPEI = ZoneInfo("Asia/Taipei") + + +async def build_ai_agent_report_source_health(days: int = 30) -> dict[str, Any]: + """Build the report source health read model.""" + generated_at = datetime.now(_TAIPEI).isoformat() + + status_board = await _load_status_board() + incident_summary = await _read_source( + source_id="incident_summary", + display_name="事件統計 read model", + route="/api/v1/stats/incidents/summary", + work_item_id="report-source-gap:incident_summary", + reader=lambda: _read_incident_summary(days), + extractor=lambda payload: { + "total": _as_int(payload.get("total_incidents") or payload.get("total")), + "resolved_rate": _as_float(payload.get("resolved_rate")), + }, + next_action="接入 redacted public incident summary,確認 Alertmanager 入庫、recurrence mirror 與 freshness。", + ) + resolution_stats = await _read_source( + source_id="resolution_stats", + display_name="解決率 read model", + route="/api/v1/stats/incidents/resolution", + work_item_id="report-source-gap:resolution_stats", + reader=lambda: _read_resolution_stats(days), + extractor=lambda payload: { + "avg_minutes": payload.get("avg_minutes"), + "resolution_rate": payload.get("resolutionRate") or payload.get("resolution_rate"), + }, + next_action="接入 redacted public resolution stats,確認 resolved_at、duration 與 postmortem 寫回。", + ) + ai_performance = await _read_source( + source_id="ai_performance", + display_name="AI 效能 read model", + route="/api/v1/stats/ai-performance", + work_item_id="report-source-gap:ai_performance", + reader=lambda: _read_ai_performance(days), + extractor=lambda payload: { + "proposal_count": _as_int(payload.get("total_proposals")), + "executed_count": _as_int(payload.get("executed_count")), + "success_rate": _as_float(payload.get("success_rate")), + }, + next_action="接入 redacted public AI performance stats,確認提案、執行、成功率與 fallback reason。", + ) + disposition_stats = await _read_source( + source_id="disposition_stats", + display_name="處置統計 read model", + route="/api/v1/stats/disposition", + work_item_id="report-source-gap:disposition_stats", + reader=_read_disposition_stats, + extractor=lambda payload: payload, + next_action="處置統計可讀時仍需追蹤 auto repair、manual handoff、cold-start trust 的占比。", + ) + status_board_source = _build_status_board_source(status_board) + + sources = [ + incident_summary, + resolution_stats, + ai_performance, + disposition_stats, + status_board_source, + ] + ok_count = sum(1 for source in sources if source["source_ok"]) + gap_sources = [source for source in sources if not source["source_ok"]] + all_zero = _is_all_zero(incident_summary, ai_performance, disposition_stats) + source_count = len(sources) + confidence_percent = round(ok_count / source_count * 100) if source_count else 0 + if all_zero and gap_sources: + confidence_percent = min(confidence_percent, 40) + + no_send_previews = _build_no_send_previews(status_board, ok_count, source_count, gap_sources) + work_items = _build_work_items(gap_sources, all_zero) + automation_assets = _build_automation_assets(status_board, work_items) + + return { + "schema_version": _SCHEMA_VERSION, + "generated_at": generated_at, + "program_status": { + "current_task_id": _CURRENT_TASK_ID, + "next_task_id": _NEXT_TASK_ID, + "overall_completion_percent": 100, + "read_only_mode": True, + "runtime_authority": _RUNTIME_AUTHORITY, + "status_note": "日報 / 週報 / 月報資料源健康與 no-send preview 已可由 API 統一讀回。", + }, + "source_health": sources, + "all_zero_assessment": { + "all_zero_observed": all_zero, + "verdict": "source_gap_or_no_signal_requires_review" if all_zero else "signals_available_or_not_all_zero", + "confidence_percent": confidence_percent, + "blocking_reason": "資料源缺口存在時,全 0 不可視為健康。" if all_zero and gap_sources else "", + "next_action": ( + "先處理 report-source-gap,再產生日報 / 週報 / 月報草案。" + if gap_sources + else "持續用趨勢、recurrence 與 verifier 判讀是否需要 AI 接手。" + ), + }, + "no_send_previews": no_send_previews, + "automation_assets": automation_assets, + "work_items": work_items, + "activation_boundaries": { + "telegram_send_enabled": False, + "gateway_queue_write_enabled": False, + "scheduler_change_enabled": False, + "ai_runtime_execution_enabled": False, + "medium_low_auto_execution_enabled": False, + "production_write_enabled": False, + "secret_read_enabled": False, + }, + "rollups": { + "source_count": source_count, + "source_ok_count": ok_count, + "source_gap_count": len(gap_sources), + "confidence_percent": confidence_percent, + "no_send_preview_count": len(no_send_previews), + "report_work_item_count": len(work_items), + "live_send_allowed_count": 0, + "runtime_gate_count": 0, + }, + } + + +async def _load_status_board() -> dict[str, Any] | None: + try: + return await asyncio.to_thread(load_latest_ai_agent_report_status_board) + except Exception as exc: + logger.warning("report_source_health_status_board_failed", error=str(exc)) + return None + + +async def _read_source( + *, + source_id: str, + display_name: str, + route: str, + work_item_id: str, + reader: Callable[[], Awaitable[dict[str, Any]]], + extractor: Callable[[dict[str, Any]], dict[str, Any]], + next_action: str, +) -> dict[str, Any]: + try: + payload = await reader() + metrics = extractor(payload or {}) + return { + "source_id": source_id, + "display_name": display_name, + "route": route, + "source_ok": True, + "state": "ok", + "freshness": "live_readback", + "confidence_percent": 100, + "metrics": metrics, + "work_item_id": "", + "next_action": next_action, + } + except Exception as exc: + logger.warning("report_source_health_reader_failed", source_id=source_id, error=str(exc)) + return { + "source_id": source_id, + "display_name": display_name, + "route": route, + "source_ok": False, + "state": "gap", + "freshness": "unavailable", + "confidence_percent": 0, + "metrics": {}, + "work_item_id": work_item_id, + "next_action": next_action, + } + + +async def _read_incident_summary(days: int) -> dict[str, Any]: + from src.services.stats_service import get_stats_service + + return await get_stats_service().get_incident_summary(days=days) + + +async def _read_resolution_stats(days: int) -> dict[str, Any]: + from src.services.stats_service import get_stats_service + + return await get_stats_service().get_resolution_stats(days=days) + + +async def _read_ai_performance(days: int) -> dict[str, Any]: + from src.services.stats_service import get_stats_service + + return await get_stats_service().get_ai_performance(days=days) + + +async def _read_disposition_stats() -> dict[str, Any]: + from src.services.anomaly_counter import get_anomaly_counter + + summary, _ = await get_anomaly_counter().get_all_disposition_stats() + total = _as_int(summary.get("total")) + auto = _as_int(summary.get("auto_repair")) + _as_int(summary.get("cold_start_trust")) + return { + "total": total, + "auto_repair": _as_int(summary.get("auto_repair")), + "human_approved": _as_int(summary.get("human_approved")), + "manual_resolved": _as_int(summary.get("manual_resolved")), + "cold_start_trust": _as_int(summary.get("cold_start_trust")), + "auto_rate": round(auto / total, 4) if total else 0, + } + + +def _build_status_board_source(status_board: dict[str, Any] | None) -> dict[str, Any]: + if not status_board: + return { + "source_id": "report_status_board", + "display_name": "日報 / 週報 / 月報狀態板", + "route": "/api/v1/agents/agent-report-status-board", + "source_ok": False, + "state": "gap", + "freshness": "unavailable", + "confidence_percent": 0, + "metrics": {}, + "work_item_id": "report-source-gap:status_board", + "next_action": "確認 committed snapshot、API route 與 no-send preview contract。", + } + rollups = status_board.get("rollups") or {} + return { + "source_id": "report_status_board", + "display_name": "日報 / 週報 / 月報狀態板", + "route": "/api/v1/agents/agent-report-status-board", + "source_ok": True, + "state": "ok", + "freshness": "committed_snapshot", + "confidence_percent": 100, + "metrics": { + "report_card_count": _as_int(rollups.get("report_card_count")), + "agent_status_count": _as_int(rollups.get("agent_status_count")), + "workload_done_total": _as_int(rollups.get("workload_done_total")), + "workload_unit_total": _as_int(rollups.get("workload_unit_total")), + "live_delivery_count": _as_int(rollups.get("live_delivery_count")), + }, + "work_item_id": "", + "next_action": "狀態板可讀;下一步接 no-send preview freshness 與 SRE 戰情室 digest route。", + } + + +def _build_no_send_previews( + status_board: dict[str, Any] | None, + ok_count: int, + source_count: int, + gap_sources: list[dict[str, Any]], +) -> list[dict[str, Any]]: + cards = (status_board or {}).get("report_status_cards") or [] + gap_ids = [source["source_id"] for source in gap_sources] + previews = [] + for card in cards: + previews.append({ + "cadence_id": card.get("cadence_id"), + "display_name": card.get("display_name"), + "owner_agent": card.get("owner_agent"), + "delivery_state": "no_send_preview", + "source_ready_count": ok_count, + "source_total_count": source_count, + "blocked_by_source_gap": bool(gap_sources), + "gap_source_ids": gap_ids, + "live_send_allowed": False, + "gateway_queue_write_allowed": False, + "next_gate": "補齊 source health 與 receipt readback 後,仍需人工批准才可實發。", + }) + return previews + + +def _build_work_items(gap_sources: list[dict[str, Any]], all_zero: bool) -> list[dict[str, Any]]: + work_items = [ + { + "work_item_id": source["work_item_id"], + "title": f"補齊 {source['display_name']} 資料鏈路", + "state": "open", + "blocking_reason": "report source unavailable", + "next_action": source["next_action"], + } + for source in gap_sources + if source.get("work_item_id") + ] + if all_zero: + work_items.append({ + "work_item_id": "report-source-gap:all_zero_truth", + "title": "全 0 報表真相判讀", + "state": "open", + "blocking_reason": "all-zero report cannot be treated as healthy", + "next_action": "比對事件、處置、AI 效能、Git / deploy 與成本來源 freshness。", + }) + return work_items + + +def _build_automation_assets( + status_board: dict[str, Any] | None, + work_items: list[dict[str, Any]], +) -> list[dict[str, Any]]: + rollups = (status_board or {}).get("rollups") or {} + return [ + { + "asset_id": "km_report_digest", + "label": "KM", + "state": "draft_ready", + "done_count": _as_int(rollups.get("agent_status_count")), + "blocked_count": len(work_items), + "next_action": "把資料缺口與 digest 結論回寫到 owner review 後的 KM 草稿。", + }, + { + "asset_id": "playbook_report_source_gap", + "label": "PlayBook", + "state": "draft_required" if work_items else "candidate_ready", + "done_count": 0 if work_items else 1, + "blocked_count": len(work_items), + "next_action": "建立 report-source-gap 專屬 PlayBook;不可用通用兜底命令。", + }, + { + "asset_id": "script_report_readback", + "label": "腳本", + "state": "readback_only", + "done_count": 1, + "blocked_count": 0, + "next_action": "保留 read-only API / no-send preview;不寫排程、不呼叫 Bot API。", + }, + { + "asset_id": "schedule_report_no_send", + "label": "排程", + "state": "no_send_preview", + "done_count": _as_int(rollups.get("report_card_count")), + "blocked_count": 0, + "next_action": "日報 / 週報 / 月報先產生草案;live delivery 仍維持 0。", + }, + { + "asset_id": "verifier_report_source_health", + "label": "Verifier", + "state": "source_health_ready", + "done_count": 1, + "blocked_count": len(work_items), + "next_action": "Verifier 需檢查 source_ok、all_zero_assessment 與 no_send_previews。", + }, + ] + + +def _is_all_zero( + incident_summary: dict[str, Any], + ai_performance: dict[str, Any], + disposition_stats: dict[str, Any], +) -> bool: + incident_total = _as_int(incident_summary.get("metrics", {}).get("total")) + proposals = _as_int(ai_performance.get("metrics", {}).get("proposal_count")) + executed = _as_int(ai_performance.get("metrics", {}).get("executed_count")) + disposition_total = _as_int(disposition_stats.get("metrics", {}).get("total")) + return incident_total == 0 and proposals == 0 and executed == 0 and disposition_total == 0 + + +def _as_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _as_float(value: Any) -> float: + try: + return float(value or 0.0) + except (TypeError, ValueError): + return 0.0 diff --git a/apps/api/tests/test_ai_agent_report_source_health_api.py b/apps/api/tests/test_ai_agent_report_source_health_api.py new file mode 100644 index 000000000..61018c2db --- /dev/null +++ b/apps/api/tests/test_ai_agent_report_source_health_api.py @@ -0,0 +1,85 @@ +from fastapi.testclient import TestClient + +from src.main import app + + +_PUBLIC_FORBIDDEN_TERMS = [ + "工作視窗", + "對話內容", + "批准!繼續", + "In app browser", + "My request for Codex", + "browser_context", + "codex_user_message", + "prompt_text", + "raw prompt", + "raw_payload", + "private reasoning", + "chain_of_thought", + "authorization header", + "secret value", + "raw Telegram payload", +] + + +def _collect_strings(value): + if isinstance(value, str): + return [value] + if isinstance(value, list): + strings = [] + for item in value: + strings.extend(_collect_strings(item)) + return strings + if isinstance(value, dict): + strings = [] + for item in value.values(): + strings.extend(_collect_strings(item)) + return strings + return [] + + +def test_get_ai_agent_report_source_health_api(): + client = TestClient(app) + response = client.get("/api/v1/agents/agent-report-source-health") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "ai_agent_report_source_health_v1" + assert data["program_status"]["current_task_id"] == "P2-109" + assert data["program_status"]["next_task_id"] == "P2-110" + assert data["program_status"]["read_only_mode"] is True + assert data["program_status"]["overall_completion_percent"] == 100 + assert data["rollups"]["source_count"] == 5 + assert data["rollups"]["no_send_preview_count"] == 3 + assert data["rollups"]["live_send_allowed_count"] == 0 + assert data["rollups"]["runtime_gate_count"] == 0 + assert data["activation_boundaries"]["telegram_send_enabled"] is False + assert data["activation_boundaries"]["gateway_queue_write_enabled"] is False + assert data["activation_boundaries"]["scheduler_change_enabled"] is False + assert data["activation_boundaries"]["ai_runtime_execution_enabled"] is False + assert data["activation_boundaries"]["production_write_enabled"] is False + + source_ids = {source["source_id"] for source in data["source_health"]} + assert source_ids == { + "incident_summary", + "resolution_stats", + "ai_performance", + "disposition_stats", + "report_status_board", + } + asset_labels = {asset["label"] for asset in data["automation_assets"]} + assert asset_labels == {"KM", "PlayBook", "腳本", "排程", "Verifier"} + for preview in data["no_send_previews"]: + assert preview["delivery_state"] == "no_send_preview" + assert preview["live_send_allowed"] is False + assert preview["gateway_queue_write_allowed"] is False + + +def test_get_ai_agent_report_source_health_api_redacts_public_terms(): + client = TestClient(app) + response = client.get("/api/v1/agents/agent-report-source-health") + + assert response.status_code == 200 + all_text = "\n".join(_collect_strings(response.json())) + for term in _PUBLIC_FORBIDDEN_TERMS: + assert term not in all_text diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 4801fd06c..1fcd533ae 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2301,6 +2301,7 @@ "sections": "章節 {count}", "charts": "圖表 {count}", "work": "工作量 {count}", + "sourceReady": "來源 {ok}/{total}", "live": "live {count}", "workDone": "工作 {done}/{total}", "approval": "待審核 {count}" diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 4801fd06c..1fcd533ae 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2301,6 +2301,7 @@ "sections": "章節 {count}", "charts": "圖表 {count}", "work": "工作量 {count}", + "sourceReady": "來源 {ok}/{total}", "live": "live {count}", "workDone": "工作 {done}/{total}", "approval": "待審核 {count}" diff --git a/apps/web/src/app/[locale]/reports/page.tsx b/apps/web/src/app/[locale]/reports/page.tsx index 58af4f46a..aad213ac4 100644 --- a/apps/web/src/app/[locale]/reports/page.tsx +++ b/apps/web/src/app/[locale]/reports/page.tsx @@ -127,6 +127,58 @@ interface ReportStatusBoardSnapshot { } } +interface ReportSourceHealthItem { + source_id: string + display_name: string + source_ok: boolean + state: string + freshness: string + confidence_percent: number + metrics: Record + work_item_id: string + next_action: string +} + +interface ReportNoSendPreview { + cadence_id: string + source_ready_count: number + source_total_count: number + blocked_by_source_gap: boolean + live_send_allowed: boolean + gateway_queue_write_allowed: boolean +} + +interface ReportAutomationAsset { + asset_id: string + label: string + state: string + done_count: number + blocked_count: number + next_action: string +} + +interface ReportSourceHealthSnapshot { + source_health: ReportSourceHealthItem[] + all_zero_assessment: { + all_zero_observed: boolean + confidence_percent: number + verdict: string + blocking_reason: string + next_action: string + } + no_send_previews: ReportNoSendPreview[] + automation_assets: ReportAutomationAsset[] + rollups: { + source_count: number + source_ok_count: number + source_gap_count: number + confidence_percent: number + report_work_item_count: number + live_send_allowed_count: number + runtime_gate_count: number + } +} + type SourceKey = 'incident' | 'resolution' | 'disposition' | 'statusBoard' type SourceState = Record @@ -154,6 +206,7 @@ export default function ReportsPage({ params }: { params: { locale: string } }) const [resolution, setResolution] = useState(null) const [disposition, setDisposition] = useState(null) const [reportBoard, setReportBoard] = useState(null) + const [sourceHealth, setSourceHealth] = useState(null) const [sourceState, setSourceState] = useState(initialSourceState) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) @@ -162,23 +215,38 @@ export default function ReportsPage({ params }: { params: { locale: string } }) setLoading(true) setError(false) Promise.all([ - Promise.resolve(null as IncidentSummary | null), - Promise.resolve(null as ResolutionStats | null), fetchJson('/api/v1/stats/disposition'), fetchJson('/api/v1/agents/agent-report-status-board'), + fetchJson('/api/v1/agents/agent-report-source-health'), ]) - .then(([incidentSummary, resolutionStats, dispositionStats, statusBoard]) => { + .then(([dispositionStats, statusBoard, reportSourceHealth]) => { + const healthById = new Map((reportSourceHealth?.source_health ?? []).map(item => [item.source_id, item])) + const incidentMetrics = healthById.get('incident_summary')?.metrics ?? {} + const resolutionMetrics = healthById.get('resolution_stats')?.metrics ?? {} + const incidentSummary: IncidentSummary | null = healthById.get('incident_summary')?.source_ok + ? { + total_incidents: Number(incidentMetrics.total ?? 0), + resolved_rate: Number(incidentMetrics.resolved_rate ?? 0), + } + : null + const resolutionStats: ResolutionStats | null = healthById.get('resolution_stats')?.source_ok + ? { + resolutionRate: Number(resolutionMetrics.resolution_rate ?? 0), + avg_minutes: typeof resolutionMetrics.avg_minutes === 'number' ? resolutionMetrics.avg_minutes : null, + } + : null setSummary(incidentSummary) setResolution(resolutionStats) setDisposition(dispositionStats) setReportBoard(statusBoard) + setSourceHealth(reportSourceHealth) setSourceState({ - incident: Boolean(incidentSummary), - resolution: Boolean(resolutionStats), - disposition: Boolean(dispositionStats), - statusBoard: Boolean(statusBoard), + incident: Boolean(healthById.get('incident_summary')?.source_ok), + resolution: Boolean(healthById.get('resolution_stats')?.source_ok), + disposition: Boolean(healthById.get('disposition_stats')?.source_ok), + statusBoard: Boolean(healthById.get('report_status_board')?.source_ok), }) - setError(!incidentSummary && !resolutionStats && !dispositionStats && !statusBoard) + setError(!dispositionStats && !statusBoard && !reportSourceHealth) }) .catch(() => setError(true)) .finally(() => setLoading(false)) @@ -199,9 +267,9 @@ export default function ReportsPage({ params }: { params: { locale: string } }) const avgResolutionTime = resolution?.avgResolutionTime ?? ( resolution?.avg_minutes != null ? `${Math.round(resolution.avg_minutes)}m` : undefined ) - const sourceOkCount = Object.values(sourceState).filter(Boolean).length - const sourceTotal = Object.keys(sourceState).length - const allZeroSignal = Boolean( + const sourceOkCount = sourceHealth?.rollups.source_ok_count ?? Object.values(sourceState).filter(Boolean).length + const sourceTotal = sourceHealth?.rollups.source_count ?? Object.keys(sourceState).length + const allZeroSignal = sourceHealth?.all_zero_assessment.all_zero_observed ?? Boolean( sourceOkCount > 0 && incidentTotal === 0 && (ds?.total ?? 0) === 0 && @@ -216,13 +284,24 @@ export default function ReportsPage({ params }: { params: { locale: string } }) const liveDelivery = reportBoard?.rollups?.live_delivery_count ?? 0 const liveOptimization = reportBoard?.rollups?.live_auto_optimization_count ?? 0 const sourceRows = useMemo( - () => [ - { key: 'incident' as const, label: t('sources.incident'), ok: sourceState.incident, next: t('sourceNext.incident') }, - { key: 'resolution' as const, label: t('sources.resolution'), ok: sourceState.resolution, next: t('sourceNext.resolution') }, - { key: 'disposition' as const, label: t('sources.disposition'), ok: sourceState.disposition, next: t('sourceNext.disposition') }, - { key: 'statusBoard' as const, label: t('sources.statusBoard'), ok: sourceState.statusBoard, next: t('sourceNext.statusBoard') }, - ], - [sourceState, t] + () => sourceHealth?.source_health?.length + ? sourceHealth.source_health.map(row => ({ + key: row.source_id, + label: row.display_name, + ok: row.source_ok, + next: row.work_item_id ? `${row.work_item_id} · ${row.next_action}` : row.next_action, + })) + : [ + { key: 'incident', label: t('sources.incident'), ok: sourceState.incident, next: t('sourceNext.incident') }, + { key: 'resolution', label: t('sources.resolution'), ok: sourceState.resolution, next: t('sourceNext.resolution') }, + { key: 'disposition', label: t('sources.disposition'), ok: sourceState.disposition, next: t('sourceNext.disposition') }, + { key: 'statusBoard', label: t('sources.statusBoard'), ok: sourceState.statusBoard, next: t('sourceNext.statusBoard') }, + ], + [sourceHealth, sourceState, t] + ) + const previewByCadence = useMemo( + () => new Map((sourceHealth?.no_send_previews ?? []).map(preview => [preview.cadence_id, preview])), + [sourceHealth] ) const pcts = useMemo(() => { @@ -358,6 +437,10 @@ export default function ReportsPage({ params }: { params: { locale: string } }) t('chip.sections', { count: card.sections_count }), t('chip.charts', { count: card.chart_count }), t('chip.work', { count: card.work_units_total }), + t('chip.sourceReady', { + ok: previewByCadence.get(card.cadence_id)?.source_ready_count ?? sourceOkCount, + total: previewByCadence.get(card.cadence_id)?.source_total_count ?? sourceTotal, + }), t('chip.live', { count: card.live_delivery_count }), ]} /> @@ -384,10 +467,23 @@ export default function ReportsPage({ params }: { params: { locale: string } })
} title={t('assets.title')} subtitle={t('assets.subtitle')} />
- - - - 0} /> + {(sourceHealth?.automation_assets ?? []).map(asset => ( + 0} + /> + ))} + {!sourceHealth?.automation_assets?.length && ( + <> + + + + 0} /> + + )}