diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 9c87c391..e283a4e4 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -1300,6 +1300,76 @@ def _status_chain_execution_section(truth_chain: dict[str, Any] | None) -> dict[ } +def _source_ref_values(envelope: Any, key: str) -> list[str]: + if not isinstance(envelope, dict): + return [] + source_refs = envelope.get("source_refs") + if not isinstance(source_refs, dict): + return [] + raw_values = source_refs.get(key) + if isinstance(raw_values, list): + return [str(item) for item in raw_values if str(item or "").strip()] + if raw_values not in (None, ""): + return [str(raw_values)] + return [] + + +def _status_chain_source_section(truth_chain: dict[str, Any] | None) -> dict[str, Any]: + channel = truth_chain.get("channel") if isinstance(truth_chain, dict) else {} + if not isinstance(channel, dict): + channel = {} + inbound_events = channel.get("inbound_events") + outbound_messages = channel.get("outbound_messages") + if not isinstance(inbound_events, list): + inbound_events = [] + if not isinstance(outbound_messages, list): + outbound_messages = [] + + source_refs: dict[str, list[str]] = { + "alert_ids": [], + "sentry_issue_ids": [], + "signoz_alerts": [], + "fingerprints": [], + "incident_ids": [], + } + inbound_channels: list[str] = [] + for row in inbound_events: + if not isinstance(row, dict): + continue + _append_unique(inbound_channels, row.get("channel_type")) + envelope = row.get("source_envelope") + for key in source_refs: + for value in _source_ref_values(envelope, key): + _append_unique(source_refs[key], value) + + latest_inbound = inbound_events[0] if inbound_events and isinstance(inbound_events[0], dict) else {} + latest_outbound = ( + outbound_messages[0] + if outbound_messages and isinstance(outbound_messages[0], dict) + else {} + ) + + return { + "inbound_total": len(inbound_events), + "outbound_total": len(outbound_messages), + "inbound_channels": inbound_channels[:5], + "refs": {key: values[:5] for key, values in source_refs.items()}, + "latest_inbound": { + "channel_type": latest_inbound.get("channel_type"), + "provider_event_id": latest_inbound.get("provider_event_id"), + "content_type": latest_inbound.get("content_type"), + "is_duplicate": latest_inbound.get("is_duplicate"), + "received_at": latest_inbound.get("received_at"), + }, + "latest_outbound": { + "channel_type": latest_outbound.get("channel_type"), + "message_type": latest_outbound.get("message_type"), + "send_status": latest_outbound.get("send_status"), + "sent_at": latest_outbound.get("sent_at"), + }, + } + + def _build_awooop_status_chain( *, incident_ids: list[str], @@ -1379,6 +1449,7 @@ def _build_awooop_status_chain( mcp_section = _status_chain_mcp_section(truth_chain) execution_section = _status_chain_execution_section(truth_chain) + source_section = _status_chain_source_section(truth_chain) blockers = [ str(item) for item in [ @@ -1422,6 +1493,7 @@ def _build_awooop_status_chain( }, "mcp": mcp_section, "execution": execution_section, + "source_refs": source_section, } diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 72b27d98..36bd8a10 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -539,6 +539,33 @@ def test_awooop_status_chain_marks_verified_repair() -> None: "not_used_reason": None, }, }, + "channel": { + "inbound_events": [ + { + "channel_type": "alertmanager", + "provider_event_id": "alert-1", + "content_type": "application/json", + "is_duplicate": False, + "received_at": "2026-05-20T00:00:00Z", + "source_envelope": { + "source_refs": { + "alert_ids": ["alert-1"], + "sentry_issue_ids": ["SENTRY-1"], + "signoz_alerts": ["signoz:abc"], + "fingerprints": ["fp-1"], + } + }, + } + ], + "outbound_messages": [ + { + "channel_type": "telegram", + "message_type": "incident_detail", + "send_status": "sent", + "sent_at": "2026-05-20T00:01:00Z", + } + ], + }, }, remediation_history={ "total": 1, @@ -571,6 +598,10 @@ def test_awooop_status_chain_marks_verified_repair() -> None: assert chain["execution"]["playbook_ids"] == ["pb-host-restart"] assert chain["execution"]["ansible"]["considered"] is True assert chain["execution"]["ansible"]["candidate_count"] == 1 + assert chain["source_refs"]["inbound_total"] == 1 + assert chain["source_refs"]["outbound_total"] == 1 + assert chain["source_refs"]["refs"]["sentry_issue_ids"] == ["SENTRY-1"] + assert chain["source_refs"]["refs"]["signoz_alerts"] == ["signoz:abc"] def test_awooop_status_chain_marks_read_only_manual_gate() -> None: diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index da4daa38..dac34236 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -436,6 +436,7 @@ "flowExecutionAnsibleConsidered": "considered ({records} records / {candidates} candidates)", "flowExecutionAnsibleNotUsed": "not used: {reason}", "flowExecutionAnsibleEmpty": "--", + "flowSourceRefsDetail": "Source detail: Inbound {inbound} / Outbound {outbound}; Alert {alert}; Sentry {sentry}; SigNoz {signoz}; latest {latest}", "flowTruthChainCurrent": "{stage} / {status}", "flowComplete": "Complete", "flowStages": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 46a08f58..3a93632a 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -437,6 +437,7 @@ "flowExecutionAnsibleConsidered": "已納入 ({records} records / {candidates} candidates)", "flowExecutionAnsibleNotUsed": "未使用:{reason}", "flowExecutionAnsibleEmpty": "--", + "flowSourceRefsDetail": "來源明細:Inbound {inbound} / Outbound {outbound};Alert {alert};Sentry {sentry};SigNoz {signoz};最新 {latest}", "flowTruthChainCurrent": "{stage} / {status}", "flowComplete": "已完成", "flowStages": { diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index f09adb5c..87c5ceb5 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -88,6 +88,31 @@ export interface AwoooPStatusChain { }>; }; }; + source_refs?: { + inbound_total?: number | null; + outbound_total?: number | null; + inbound_channels?: string[]; + refs?: { + alert_ids?: string[]; + sentry_issue_ids?: string[]; + signoz_alerts?: string[]; + fingerprints?: string[]; + incident_ids?: string[]; + }; + latest_inbound?: { + channel_type?: string | null; + provider_event_id?: string | null; + content_type?: string | null; + is_duplicate?: boolean | null; + received_at?: string | null; + }; + latest_outbound?: { + channel_type?: string | null; + message_type?: string | null; + send_status?: string | null; + sent_at?: string | null; + }; + }; } function toneClass(chain?: AwoooPStatusChain | null) { diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx index 262a9e6b..6190105d 100644 --- a/apps/web/src/components/incident/incident-card.tsx +++ b/apps/web/src/components/incident/incident-card.tsx @@ -380,6 +380,22 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange playbook: executionPlaybook, }) : null + const sourceRefs = statusChain?.source_refs + const latestSource = sourceRefs?.latest_inbound?.channel_type + ? `${sourceRefs.latest_inbound.channel_type}/${chainValue(sourceRefs.latest_inbound.provider_event_id)}` + : sourceRefs?.latest_outbound?.channel_type + ? `${sourceRefs.latest_outbound.channel_type}/${chainValue(sourceRefs.latest_outbound.message_type)}` + : '--' + const statusChainSourceRefDetail = hasTruthChain + ? t('flowSourceRefsDetail', { + inbound: sourceRefs?.inbound_total ?? 0, + outbound: sourceRefs?.outbound_total ?? 0, + alert: sourceRefs?.refs?.alert_ids?.length ?? 0, + sentry: sourceRefs?.refs?.sentry_issue_ids?.length ?? 0, + signoz: sourceRefs?.refs?.signoz_alerts?.length ?? 0, + latest: latestSource, + }) + : null const serviceName = incident.affected_services?.[0] ?? '--' const duration = formatDuration(incident.created_at) @@ -659,6 +675,10 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange {statusChainExecutionDetail} + / + + {statusChainSourceRefDetail} + )}