diff --git a/apps/api/src/services/awoooi_priority_work_order_readback.py b/apps/api/src/services/awoooi_priority_work_order_readback.py index 97089330d..18c8e2479 100644 --- a/apps/api/src/services/awoooi_priority_work_order_readback.py +++ b/apps/api/src/services/awoooi_priority_work_order_readback.py @@ -47,6 +47,20 @@ _METRIC_LINE_RE = re.compile( _COMMANDER_INSERTED_REQUIREMENT_SOURCE = ( "docs/workplans/2026-07-02-commander-inserted-requirements-priority-ledger.md" ) +_AI_AUTOMATION_NODE_RECEIPT_SCHEMA_VERSION = "ai_automation_node_receipt_v1" +_AI_AUTOMATION_NODE_RECEIPT_REQUIRED_FIELDS = [ + "trace_id", + "run_id", + "work_item_id", + "node_id", + "node_status", + "timestamp", + "evidence_refs", + "verifier_result", + "rollback_result", + "km_writeback_ref", + "playbook_trust_writeback_ref", +] _COMMANDER_INSERTED_REQUIREMENT_WORK_ITEMS: list[dict[str, Any]] = [ { "id": "CIR-P0-001", @@ -816,6 +830,7 @@ def load_latest_awoooi_priority_work_order_readback( _require_mainline_consistency(payload, str(path)) _apply_commander_inserted_requirement_work_items(payload) _mark_runtime_generated_at(payload) + apply_ai_automation_node_receipts(payload) return payload @@ -965,6 +980,7 @@ def apply_controlled_cd_lane_live_metric_readback( ) _mark_runtime_generated_at(payload) + apply_ai_automation_node_receipts(payload) def apply_stockplatform_public_api_runtime_readback( @@ -1516,6 +1532,7 @@ def apply_ai_loop_current_blocker_execution_queue( pressure_blocker=pressure_blocker, node_load_classifier=node_load_classifier, ) + apply_ai_automation_node_receipts(payload) return if not _ai_loop_current_blocker_can_override( @@ -2101,6 +2118,7 @@ def apply_ai_loop_current_blocker_execution_queue( summary["ai_loop_current_blocker_forbidden_runtime_action_count"] = len( forbidden_runtime_actions ) + apply_ai_automation_node_receipts(payload) def _record_ai_loop_current_blocker_production_resolution( @@ -2226,6 +2244,7 @@ def _record_ai_loop_current_blocker_production_resolution( if blocker and blocker not in resolved_blockers ] break + apply_ai_automation_node_receipts(payload) def _ai_loop_current_blocker_can_override( @@ -3371,6 +3390,213 @@ def _apply_commander_inserted_requirement_work_items( ) +def apply_ai_automation_node_receipts(payload: dict[str, Any]) -> None: + """Refresh metadata-only AI automation node receipts after live overlays.""" + _apply_ai_automation_node_receipts(payload) + + +def _apply_ai_automation_node_receipts(payload: dict[str, Any]) -> None: + summary = _dict(payload.setdefault("summary", {})) + rollups = _dict(payload.setdefault("rollups", {})) + runtime_timestamp = str( + payload.get("runtime_readback_generated_at") + or summary.get("runtime_readback_generated_at") + or payload.get("generated_at") + or "" + ) + source_sha = str(summary.get("latest_successful_deployed_source_short_sha") or "") + cd_run_id = str(summary.get("ai_loop_current_blocker_current_cd_run_id") or "") + cd_run_status = str( + summary.get("ai_loop_current_blocker_current_cd_run_status") or "" + ) + blocker_id = str(summary.get("ai_loop_current_blocker_id") or "") + safe_next_action_id = str( + summary.get("ai_loop_current_blocker_safe_next_action_id") or "" + ) + safe_next_action_stage = str( + summary.get("ai_loop_current_blocker_safe_next_action_stage") or "" + ) + receipt_inputs = _strings( + summary.get("ai_loop_current_blocker_harbor_recovery_receipt_input_ids") + ) + receipt_outputs = _strings( + summary.get("ai_loop_current_blocker_harbor_recovery_receipt_output_ids") + ) + queue_fields = _strings( + summary.get("ai_loop_current_blocker_queue_readback_normalizer_field_ids") + ) + log_grouping_keys = _strings(summary.get("ai_loop_log_source_grouping_keys")) + + base_refs = [ + "awoooi-priority-work-order-readback", + _COMMANDER_INSERTED_REQUIREMENT_SOURCE, + ] + if source_sha: + base_refs.append(f"production_source:{source_sha}") + if cd_run_id: + base_refs.append(f"gitea_cd_run:{cd_run_id}:{cd_run_status or 'unknown'}") + + node_specs = [ + { + "node_id": "sensor_evidence", + "node_label": "Sensor / Evidence", + "node_status": "ready", + "work_item_id": "CIR-P0-AILOOP-001", + "lane_id": "p0_runtime_lane", + "evidence_refs": [*base_refs, *receipt_inputs[:3]], + "verifier_result": "production_readback_present", + "next_action": "keep source/CD/runtime readback attached to this trace", + }, + { + "node_id": "normalizer_log_tags", + "node_label": "Normalizer / LOG tags", + "node_status": "ready" if queue_fields or log_grouping_keys else "queued", + "work_item_id": "CIR-P0-LOG-002", + "lane_id": "log_tagging_classification_clustering", + "evidence_refs": [*queue_fields[:4], *log_grouping_keys[:4]], + "verifier_result": "queue_normalizer_fields_readback" + if queue_fields + else "normalizer_contract_required", + "next_action": "extend tag taxonomy to Telegram, CPU, backup, freshness", + }, + { + "node_id": "ai_lane_route", + "node_label": "AI Lane", + "node_status": "ready", + "work_item_id": "CIR-P0-AILOOP-001", + "lane_id": "ai_loop_agent_mainline", + "evidence_refs": [blocker_id, safe_next_action_stage], + "verifier_result": "ai_loop_current_blocker_routed", + "next_action": "keep every alert/work item attached to an AI lane", + }, + { + "node_id": "candidate_action", + "node_label": "Candidate", + "node_status": "ready" if safe_next_action_id else "queued", + "work_item_id": "CIR-P0-AILOOP-002", + "lane_id": "p0_runtime_lane", + "evidence_refs": [safe_next_action_id, safe_next_action_stage], + "verifier_result": "safe_next_action_readback" + if safe_next_action_id + else "candidate_action_required", + "next_action": "emit candidate action with target selector and dry-run", + }, + { + "node_id": "gate_policy", + "node_label": "Gate", + "node_status": "ready", + "work_item_id": "CIR-P0-013", + "lane_id": "controlled_apply_gate_taxonomy", + "evidence_refs": ["non_accident_gate_as_evidence", "break_glass_reserved"], + "verifier_result": "controlled_apply_policy_readback", + "next_action": "keep low/medium/high in controlled apply unless hard blocker", + }, + { + "node_id": "execution_boundary", + "node_label": "Execution Boundary", + "node_status": "queued", + "work_item_id": "CIR-P0-AILOOP-002", + "lane_id": "telegram_alert_ai_automation", + "evidence_refs": ["metadata_only", "no_secret_no_runtime_write"], + "verifier_result": "execution_receipt_required", + "next_action": "attach Telegram alert receipt and controlled executor result", + }, + { + "node_id": "post_verifier", + "node_label": "Verifier", + "node_status": "ready" if receipt_outputs else "queued", + "work_item_id": "CIR-P0-AILOOP-002", + "lane_id": "p0_runtime_lane", + "evidence_refs": receipt_outputs[:4], + "verifier_result": "post_verifier_receipt_readback" + if receipt_outputs + else "post_verifier_required", + "next_action": "read back route/API/UI/verifier output before closing", + }, + { + "node_id": "learning_writeback", + "node_label": "Learning / Writeback", + "node_status": "queued", + "work_item_id": "CIR-P1-KM-002", + "lane_id": "km_rag_mcp_playbook_integration", + "evidence_refs": ["KM", "RAG", "MCP", "PlayBook trust"], + "verifier_result": "writeback_receipt_required", + "next_action": "write KM/RAG/MCP/PlayBook trust refs after verifier", + }, + ] + + receipts = [] + for index, node in enumerate(node_specs, start=1): + receipts.append( + { + "schema_version": _AI_AUTOMATION_NODE_RECEIPT_SCHEMA_VERSION, + "trace_id": "trace:awoooi-ai-loop-current-p0", + "run_id": cd_run_id or f"priority-readback:{source_sha or 'runtime'}", + "node_order": index, + "timestamp": runtime_timestamp, + "metadata_only": True, + "rollback_result": "not_started_no_runtime_write", + "km_writeback_ref": "" + if node["node_status"] != "queued" + else "queued", + "playbook_trust_writeback_ref": "" + if node["node_status"] != "queued" + else "queued", + "mcp_evidence_ref": "metadata_registry", + "telegram_receipt_ref": "metadata_only" + if node["lane_id"] == "telegram_alert_ai_automation" + else "", + **node, + } + ) + + status_counts = { + status: sum(1 for item in receipts if item["node_status"] == status) + for status in ("ready", "queued", "blocked") + } + lanes = sorted({str(item["lane_id"]) for item in receipts}) + work_items = sorted({str(item["work_item_id"]) for item in receipts}) + + payload["ai_automation_node_receipt_schema"] = { + "schema_version": _AI_AUTOMATION_NODE_RECEIPT_SCHEMA_VERSION, + "required_fields": _AI_AUTOMATION_NODE_RECEIPT_REQUIRED_FIELDS, + "metadata_only": True, + "hard_blockers_preserved": [ + "secret_plaintext", + "destructive_data_change", + "reboot_or_node_drain", + "force_push_or_repo_ref_delete", + "paid_provider_route_change", + ], + } + payload["ai_automation_node_receipts"] = receipts + + rollups["ai_automation_node_receipt_count"] = len(receipts) + rollups["ai_automation_node_receipt_ready_count"] = status_counts["ready"] + rollups["ai_automation_node_receipt_queued_count"] = status_counts["queued"] + rollups["ai_automation_node_receipt_blocked_count"] = status_counts["blocked"] + rollups["ai_automation_node_receipt_required_field_count"] = len( + _AI_AUTOMATION_NODE_RECEIPT_REQUIRED_FIELDS + ) + rollups["ai_automation_node_receipt_metadata_only"] = True + rollups["ai_automation_node_receipt_lanes"] = lanes + rollups["ai_automation_node_receipt_work_items"] = work_items + + summary["ai_automation_node_receipt_schema_version"] = ( + _AI_AUTOMATION_NODE_RECEIPT_SCHEMA_VERSION + ) + summary["ai_automation_node_receipt_count"] = len(receipts) + summary["ai_automation_node_receipt_ready_count"] = status_counts["ready"] + summary["ai_automation_node_receipt_queued_count"] = status_counts["queued"] + summary["ai_automation_node_receipt_blocked_count"] = status_counts["blocked"] + summary["ai_automation_node_receipt_required_field_count"] = len( + _AI_AUTOMATION_NODE_RECEIPT_REQUIRED_FIELDS + ) + summary["ai_automation_node_receipt_metadata_only"] = True + summary["ai_automation_node_receipt_lanes"] = lanes + summary["ai_automation_node_receipt_work_items"] = work_items + + def _mark_runtime_generated_at(payload: dict[str, Any]) -> None: snapshot_generated_at = str( payload.get("snapshot_generated_at") or payload.get("generated_at") or "" diff --git a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py index 50ee73540..8753eba8b 100644 --- a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py +++ b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py @@ -395,6 +395,67 @@ def test_awoooi_priority_work_order_readback_loader_returns_mainline_order(): assert "文字牆" in inserted_by_id["CIR-P2-UX-002"]["request"] assert inserted_by_id["CIR-P3-NON-001"]["status"] == "deferred" assert inserted_by_id["CIR-P3-NON-001"]["lane"] == "non_awoooi_personal_note" + expected_node_required_fields = [ + "trace_id", + "run_id", + "work_item_id", + "node_id", + "node_status", + "timestamp", + "evidence_refs", + "verifier_result", + "rollback_result", + "km_writeback_ref", + "playbook_trust_writeback_ref", + ] + receipt_schema = payload["ai_automation_node_receipt_schema"] + assert receipt_schema["schema_version"] == "ai_automation_node_receipt_v1" + assert receipt_schema["required_fields"] == expected_node_required_fields + assert receipt_schema["metadata_only"] is True + assert receipt_schema["hard_blockers_preserved"] == [ + "secret_plaintext", + "destructive_data_change", + "reboot_or_node_drain", + "force_push_or_repo_ref_delete", + "paid_provider_route_change", + ] + receipts = payload["ai_automation_node_receipts"] + assert [item["node_id"] for item in receipts] == [ + "sensor_evidence", + "normalizer_log_tags", + "ai_lane_route", + "candidate_action", + "gate_policy", + "execution_boundary", + "post_verifier", + "learning_writeback", + ] + assert all(item["schema_version"] == "ai_automation_node_receipt_v1" for item in receipts) + assert all(item["metadata_only"] is True for item in receipts) + assert all(item["rollback_result"] == "not_started_no_runtime_write" for item in receipts) + receipts_by_id = {item["node_id"]: item for item in receipts} + assert receipts_by_id["sensor_evidence"]["work_item_id"] == "CIR-P0-AILOOP-001" + assert receipts_by_id["execution_boundary"]["lane_id"] == ( + "telegram_alert_ai_automation" + ) + assert receipts_by_id["execution_boundary"]["telegram_receipt_ref"] == ( + "metadata_only" + ) + assert receipts_by_id["learning_writeback"]["work_item_id"] == "CIR-P1-KM-002" + assert receipts_by_id["learning_writeback"]["km_writeback_ref"] == "queued" + assert payload["summary"]["ai_automation_node_receipt_schema_version"] == ( + "ai_automation_node_receipt_v1" + ) + assert payload["summary"]["ai_automation_node_receipt_count"] == 8 + assert payload["summary"]["ai_automation_node_receipt_ready_count"] == 3 + assert payload["summary"]["ai_automation_node_receipt_queued_count"] == 5 + assert payload["summary"]["ai_automation_node_receipt_blocked_count"] == 0 + assert payload["summary"]["ai_automation_node_receipt_required_field_count"] == 11 + assert payload["summary"]["ai_automation_node_receipt_metadata_only"] is True + assert "p0_runtime_lane" in payload["summary"]["ai_automation_node_receipt_lanes"] + assert "CIR-P0-AILOOP-002" in payload["summary"][ + "ai_automation_node_receipt_work_items" + ] def test_awoooi_priority_work_order_readback_endpoint_returns_snapshot( @@ -480,6 +541,31 @@ def test_awoooi_priority_work_order_readback_endpoint_returns_snapshot( assert data["commander_inserted_requirement_work_items"][1]["visible_surface"] == ( "/zh-TW/awooop/work-items" ) + assert data["summary"]["ai_automation_node_receipt_schema_version"] == ( + "ai_automation_node_receipt_v1" + ) + assert data["summary"]["ai_automation_node_receipt_count"] == 8 + assert data["summary"]["ai_automation_node_receipt_ready_count"] >= 3 + assert data["summary"]["ai_automation_node_receipt_queued_count"] >= 1 + assert [item["node_id"] for item in data["ai_automation_node_receipts"]] == [ + "sensor_evidence", + "normalizer_log_tags", + "ai_lane_route", + "candidate_action", + "gate_policy", + "execution_boundary", + "post_verifier", + "learning_writeback", + ] + endpoint_receipts = { + item["node_id"]: item for item in data["ai_automation_node_receipts"] + } + assert endpoint_receipts["candidate_action"]["work_item_id"] == ( + "CIR-P0-AILOOP-002" + ) + assert endpoint_receipts["execution_boundary"]["lane_id"] == ( + "telegram_alert_ai_automation" + ) def test_controlled_cd_lane_live_metric_readback_falls_back_to_prometheus( diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d820727f8..0ac8b24f3 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -9151,6 +9151,22 @@ "inputs": "Receipt inputs", "outputs": "Receipt outputs" }, + "nodeReceipts": { + "title": "AI automation node receipts", + "subtitle": "Sensor -> Normalizer -> AI Lane -> Candidate -> Gate -> Execution -> Verifier -> Learning are exposed as readable nodes.", + "schema": "schema {schema} · required fields {count} · metadata-only {metadata}", + "counts": "ready {ready} / queued {queued} / blocked {blocked}", + "workItem": "work item", + "lane": "lane", + "verifier": "verifier", + "writeback": "writeback", + "evidence": "evidence refs {count}", + "status": { + "ready": "ready", + "queued": "queued", + "blocked": "blocked" + } + }, "tagLabels": { "projectId": "專案", "product": "產品", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index d820727f8..31535e998 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -9151,6 +9151,22 @@ "inputs": "Receipt inputs", "outputs": "Receipt outputs" }, + "nodeReceipts": { + "title": "AI 自動化節點 receipt", + "subtitle": "Sensor -> Normalizer -> AI Lane -> Candidate -> Gate -> Execution -> Verifier -> Learning 全部變成可讀節點。", + "schema": "schema {schema} · required fields {count} · metadata-only {metadata}", + "counts": "ready {ready} / queued {queued} / blocked {blocked}", + "workItem": "work item", + "lane": "lane", + "verifier": "verifier", + "writeback": "writeback", + "evidence": "evidence refs {count}", + "status": { + "ready": "ready", + "queued": "queued", + "blocked": "blocked" + } + }, "tagLabels": { "projectId": "專案", "product": "產品", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index 212dcef36..fb0a9cb8b 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -1026,6 +1026,28 @@ type AiLoopLogSourceContract = { purpose?: string | null; }; +type AiAutomationNodeReceipt = { + schema_version?: string | null; + trace_id?: string | null; + run_id?: string | null; + node_order?: number | null; + node_id?: string | null; + node_label?: string | null; + node_status?: "ready" | "queued" | "blocked" | string | null; + timestamp?: string | null; + metadata_only?: boolean | null; + rollback_result?: string | null; + km_writeback_ref?: string | null; + playbook_trust_writeback_ref?: string | null; + mcp_evidence_ref?: string | null; + telegram_receipt_ref?: string | null; + work_item_id?: string | null; + lane_id?: string | null; + evidence_refs?: string[] | null; + verifier_result?: string | null; + next_action?: string | null; +}; + type CommanderInsertedRequirementWorkItem = { id?: string | null; priority?: "P0" | "P1" | "P2" | "P3" | string | null; @@ -1096,6 +1118,15 @@ type PriorityWorkOrderResponse = { ai_loop_log_source_grouping_key_count?: number | null; ai_loop_log_source_grouping_keys?: string[] | null; ai_loop_log_source_tagging_contract_count?: number | null; + ai_automation_node_receipt_schema_version?: string | null; + ai_automation_node_receipt_count?: number | null; + ai_automation_node_receipt_ready_count?: number | null; + ai_automation_node_receipt_queued_count?: number | null; + ai_automation_node_receipt_blocked_count?: number | null; + ai_automation_node_receipt_required_field_count?: number | null; + ai_automation_node_receipt_metadata_only?: boolean | null; + ai_automation_node_receipt_lanes?: string[] | null; + ai_automation_node_receipt_work_items?: string[] | null; } | null; in_progress_or_blocked_in_priority_order?: Array<{ evidence?: { @@ -1133,6 +1164,13 @@ type PriorityWorkOrderResponse = { } | null; }>; commander_inserted_requirement_work_items?: CommanderInsertedRequirementWorkItem[]; + ai_automation_node_receipts?: AiAutomationNodeReceipt[]; + ai_automation_node_receipt_schema?: { + schema_version?: string | null; + required_fields?: string[] | null; + metadata_only?: boolean | null; + hard_blockers_preserved?: string[] | null; + } | null; }; const COMMANDER_INSERTED_REQUIREMENT_FALLBACK: PriorityWorkOrderResponse = { @@ -7915,6 +7953,17 @@ function tagDisplayLabel( return labels[key] ?? key; } +function aiAutomationNodeStatusTone(status: string) { + switch (status) { + case "ready": + return "border-[#b9d9c2] bg-[#f2fbf3] text-[#236332]"; + case "blocked": + return "border-[#f0c6a8] bg-[#fff8f1] text-[#9a4d16]"; + default: + return "border-[#c9d8ea] bg-[#eef5ff] text-[#1f5b9b]"; + } +} + function AiLoopLogSourceTagsPanel({ priority, loading, @@ -7932,6 +7981,31 @@ function AiLoopLogSourceTagsPanel({ const contracts = evidence?.ai_loop_log_source_tagging_contract ?? []; const groupingKeys = priority?.summary?.ai_loop_log_source_grouping_keys ?? []; const summary = priority?.summary; + const nodeReceipts = (priority?.ai_automation_node_receipts ?? []).filter((item) => + Boolean(item?.node_id) + ); + const nodeReceiptSchema = priority?.ai_automation_node_receipt_schema; + const nodeReceiptCounts = { + total: summary?.ai_automation_node_receipt_count ?? nodeReceipts.length, + ready: + summary?.ai_automation_node_receipt_ready_count ?? + nodeReceipts.filter((item) => item.node_status === "ready").length, + queued: + summary?.ai_automation_node_receipt_queued_count ?? + nodeReceipts.filter((item) => item.node_status === "queued").length, + blocked: + summary?.ai_automation_node_receipt_blocked_count ?? + nodeReceipts.filter((item) => item.node_status === "blocked").length, + required: + summary?.ai_automation_node_receipt_required_field_count ?? + nodeReceiptSchema?.required_fields?.length ?? + 0, + }; + const nodeStatusLabels: Record = { + ready: t("nodeReceipts.status.ready"), + queued: t("nodeReceipts.status.queued"), + blocked: t("nodeReceipts.status.blocked"), + }; const receiptInputIds = summary?.ai_loop_current_blocker_harbor_recovery_receipt_input_ids ?? evidence?.ai_loop_current_blocker_harbor_recovery_receipt_input_ids ?? @@ -8372,6 +8446,136 @@ function AiLoopLogSourceTagsPanel({ })} ) : null} + {nodeReceipts.length > 0 ? ( +
+
+
+
+
+

+ {t("nodeReceipts.subtitle")} +

+
+
+
+ {t("nodeReceipts.counts", { + ready: nodeReceiptCounts.ready, + queued: nodeReceiptCounts.queued, + blocked: nodeReceiptCounts.blocked, + })} +
+
+ {loading ? "--" : nodeReceiptCounts.total} +
+
+
+
+ {t("nodeReceipts.schema", { + schema: + summary?.ai_automation_node_receipt_schema_version ?? + nodeReceiptSchema?.schema_version ?? + "--", + count: nodeReceiptCounts.required, + metadata: String( + summary?.ai_automation_node_receipt_metadata_only ?? + nodeReceiptSchema?.metadata_only ?? + false + ), + })} +
+
+ {nodeReceipts.slice(0, 8).map((receipt) => { + const nodeId = String(receipt.node_id ?? "--"); + const nodeStatus = String(receipt.node_status ?? "queued"); + const writeback = + [ + receipt.km_writeback_ref, + receipt.playbook_trust_writeback_ref, + ] + .filter(Boolean) + .join(" / ") || "--"; + return ( +
+
+
+
+ {String(receipt.node_order ?? "--").padStart(2, "0")} +
+
+ {receipt.node_label ?? nodeId} +
+
+ {nodeId} +
+
+ + {nodeStatusLabels[nodeStatus] ?? nodeStatus} + +
+
+
+
+ {t("nodeReceipts.workItem")} +
+
+ {receipt.work_item_id ?? "--"} +
+
+
+
+ {t("nodeReceipts.lane")} +
+
+ {receipt.lane_id ?? "--"} +
+
+
+
+ {t("nodeReceipts.verifier")} +
+
+ {receipt.verifier_result ?? "--"} +
+
+
+
+ {t("nodeReceipts.writeback")} +
+
+ {writeback} +
+
+
+
+ {t("nodeReceipts.evidence", { + count: receipt.evidence_refs?.length ?? 0, + })} +
+
+ ); + })} +
+
+ ) : null}
{metrics.map((metric) => { const Icon = metric.icon;