diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index e283a4e4..57778045 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -12,7 +12,7 @@ import re import uuid from collections import defaultdict from collections.abc import Mapping -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any, get_args from uuid import UUID @@ -30,7 +30,7 @@ from src.db.awooop_models import ( AwoooPRunStepJournal, ) from src.db.base import get_db_context -from src.db.models import MCPAuditLog +from src.db.models import IncidentRecord, MCPAuditLog from src.services.audit_sink import write_audit from src.services.awooop_approval_token import issue_approval_token, record_approval from src.services.awooop_truth_chain_service import ( @@ -87,6 +87,11 @@ _CALLBACK_REPLY_RAW_STATUS_BY_FILTER = { _CALLBACK_REPLY_ACTION_RE = re.compile(r"^[a-z0-9_:-]{1,64}$", re.IGNORECASE) _AI_ROUTE_STATUS_SCHEMA_VERSION = "awooop_ai_route_status_v1" _AI_ROUTE_WORKLOADS = set(get_args(OllamaWorkloadType)) +_SOURCE_CORRELATION_SCHEMA_VERSION = "source_provider_correlation_v1" +_SOURCE_CORRELATION_PROVIDERS = ("sentry", "signoz") +_SOURCE_CORRELATION_EVENT_LIMIT = 200 +_SOURCE_CORRELATION_LOOKBACK_DAYS = 7 +_SOURCE_CORRELATION_PRE_WINDOW_HOURS = 2 # ============================================================================= # Tenants @@ -1314,6 +1319,406 @@ def _source_ref_values(envelope: Any, key: str) -> list[str]: return [] +def _source_correlation_empty( + incident_ids: list[str], + *, + status_value: str, + missing_reason: str, +) -> dict[str, Any]: + return { + "schema_version": _SOURCE_CORRELATION_SCHEMA_VERSION, + "status": status_value, + "missing_reason": missing_reason, + "incident_ids": incident_ids, + "direct_ref_total": 0, + "candidate_total": 0, + "provider_event_total": 0, + "providers": { + provider: { + "direct_ref_total": 0, + "candidate_total": 0, + "latest_event_at": None, + "latest_heartbeat_at": None, + } + for provider in _SOURCE_CORRELATION_PROVIDERS + }, + "top_candidates": [], + "matching_criteria": [ + "direct_source_ref", + "fingerprint_overlap", + "alertname_overlap", + "service_or_namespace_overlap", + "severity_overlap", + ], + } + + +def _normalize_correlation_value(value: Any) -> str: + if hasattr(value, "value"): + value = value.value + return str(value or "").strip().lower() + + +def _append_correlation_term(values: list[str], value: Any) -> None: + term = _normalize_correlation_value(value) + if term in {"", "--", "n/a", "none", "null", "unknown"}: + return + if len(term) < 2: + return + if term not in values: + values.append(term) + + +def _intersection(left: list[str], right: list[str]) -> list[str]: + right_set = set(right) + return [item for item in left if item in right_set] + + +def _as_utc_naive(value: Any) -> datetime | None: + if not isinstance(value, datetime): + return None + if value.tzinfo is not None: + return value.astimezone(UTC).replace(tzinfo=None) + return value + + +def _iso_or_none(value: Any) -> str | None: + if hasattr(value, "isoformat"): + return value.isoformat() + if value in (None, ""): + return None + return str(value) + + +def _incident_correlation_context(record: IncidentRecord) -> dict[str, list[str]]: + """Build compact incident terms used only for read-only source matching.""" + alertnames: list[str] = [] + severities: list[str] = [] + fingerprints: list[str] = [] + namespaces: list[str] = [] + targets: list[str] = [] + + _append_correlation_term(alertnames, record.alertname) + _append_correlation_term(severities, record.severity) + for service in record.affected_services or []: + _append_correlation_term(targets, service) + + for signal in record.signals or []: + if not isinstance(signal, dict): + continue + _append_correlation_term(alertnames, signal.get("alert_name")) + _append_correlation_term(severities, signal.get("severity")) + _append_correlation_term(fingerprints, signal.get("fingerprint")) + labels = _as_dict(signal.get("labels")) + annotations = _as_dict(signal.get("annotations")) + _append_correlation_term(alertnames, labels.get("alertname")) + _append_correlation_term(fingerprints, labels.get("fingerprint")) + for key in ( + "namespace", + "kubernetes_namespace", + ): + _append_correlation_term(namespaces, labels.get(key)) + for key in ( + "service", + "service_name", + "pod", + "pod_name", + "deployment", + "deployment_name", + "container", + "job", + "instance", + "target", + "target_resource", + "workload", + "app", + "app.kubernetes.io/name", + ): + _append_correlation_term(targets, labels.get(key)) + for key in ("summary", "description"): + _append_correlation_term(alertnames, annotations.get(key)) + + return { + "incident_ids": [record.incident_id], + "alertnames": alertnames, + "severities": severities, + "fingerprints": fingerprints, + "namespaces": namespaces, + "targets": targets, + } + + +def _source_event_correlation_context(row: Mapping[str, Any]) -> dict[str, Any]: + envelope = _as_dict(row.get("source_envelope")) + source_refs = _as_dict(envelope.get("source_refs")) + log_correlation = _as_dict(envelope.get("log_correlation")) + labels = _as_dict(envelope.get("labels")) + annotations = _as_dict(envelope.get("annotations")) + + alertnames: list[str] = [] + severities: list[str] = [] + fingerprints: list[str] = [] + namespaces: list[str] = [] + targets: list[str] = [] + + _append_correlation_term(alertnames, log_correlation.get("alertname")) + _append_correlation_term(alertnames, labels.get("alertname")) + for value in _source_ref_values(envelope, "signoz_alerts"): + _append_correlation_term(alertnames, value) + _append_correlation_term(severities, log_correlation.get("severity")) + _append_correlation_term(severities, labels.get("severity")) + _append_correlation_term(fingerprints, log_correlation.get("fingerprint")) + _append_correlation_term(fingerprints, labels.get("fingerprint")) + for value in _source_ref_values(envelope, "fingerprints"): + _append_correlation_term(fingerprints, value) + for key in ("namespace", "kubernetes_namespace"): + _append_correlation_term(namespaces, log_correlation.get(key)) + _append_correlation_term(namespaces, labels.get(key)) + for key in ( + "target_resource", + "service", + "service_name", + "pod", + "pod_name", + "deployment", + "deployment_name", + "container", + "job", + "instance", + "target", + "workload", + "app", + "app.kubernetes.io/name", + ): + _append_correlation_term(targets, log_correlation.get(key)) + _append_correlation_term(targets, labels.get(key)) + for key in ("summary", "description"): + _append_correlation_term(alertnames, annotations.get(key)) + + return { + "provider": str(row.get("provider") or envelope.get("provider") or "").lower(), + "stage": str(row.get("stage") or envelope.get("stage") or ""), + "provider_event_id": row.get("provider_event_id") or envelope.get("provider_event_id"), + "received_at": row.get("received_at"), + "source_refs": source_refs, + "incident_ids": _source_ref_values(envelope, "incident_ids"), + "alertnames": alertnames, + "severities": severities, + "fingerprints": fingerprints, + "namespaces": namespaces, + "targets": targets, + } + + +def _score_source_correlation_event( + incident_context: dict[str, list[str]], + event_context: dict[str, Any], +) -> dict[str, Any]: + """Return a deterministic, read-only source-match score for UI evidence.""" + reasons: list[str] = [] + score = 0 + is_direct = False + + if _intersection(incident_context["incident_ids"], event_context["incident_ids"]): + is_direct = True + score += 100 + reasons.append("direct_incident_ref") + + fingerprint_hits = _intersection( + incident_context["fingerprints"], + event_context["fingerprints"], + ) + if fingerprint_hits: + is_direct = True + score += 80 + reasons.append("fingerprint_overlap") + + if _intersection(incident_context["alertnames"], event_context["alertnames"]): + score += 35 + reasons.append("alertname_overlap") + + if _intersection(incident_context["targets"], event_context["targets"]): + score += 25 + reasons.append("target_overlap") + + if _intersection(incident_context["namespaces"], event_context["namespaces"]): + score += 10 + reasons.append("namespace_overlap") + + if _intersection(incident_context["severities"], event_context["severities"]): + score += 5 + reasons.append("severity_overlap") + + return { + "is_direct": is_direct, + "is_candidate": bool(is_direct or score >= 35), + "score": min(score, 100), + "reasons": reasons[:5], + } + + +async def _fetch_source_correlation_summary( + *, + project_id: str, + incident_ids: list[str], +) -> dict[str, Any]: + """Fetch read-only Sentry/SigNoz evidence candidates for incident status-chain.""" + if not incident_ids: + return _source_correlation_empty( + incident_ids, + status_value="no_incident_context", + missing_reason="no_incident_ids", + ) + + safe_project_id = project_id or "awoooi" + async with get_db_context(safe_project_id) as db: + incident_result = await db.execute( + select(IncidentRecord) + .where(IncidentRecord.project_id == safe_project_id) + .where(IncidentRecord.incident_id.in_(incident_ids)) + ) + incident_rows = list(incident_result.scalars().all()) + + if not incident_rows: + heartbeat_rows = [] + source_rows = [] + else: + now = _utc_now_naive() + created_candidates = [ + value + for value in (_as_utc_naive(row.created_at) for row in incident_rows) + if value is not None + ] + earliest_created = min(created_candidates) if created_candidates else now + window_start = max( + earliest_created - timedelta(hours=_SOURCE_CORRELATION_PRE_WINDOW_HOURS), + now - timedelta(days=_SOURCE_CORRELATION_LOOKBACK_DAYS), + ) + provider_sql = ( + "LOWER(COALESCE(NULLIF(source_envelope->>'provider', ''), " + "NULLIF(split_part(provider_event_id, ':', 1), ''), channel_type))" + ) + source_result = await db.execute( + text(f""" + SELECT + event_id::text AS event_id, + project_id, + channel_type, + provider_event_id, + content_preview, + source_envelope, + received_at, + {provider_sql} AS provider, + LOWER(COALESCE(source_envelope->>'stage', '')) AS stage + FROM awooop_conversation_event + WHERE project_id = :project_id + AND {provider_sql} IN ('sentry', 'signoz') + AND LOWER(COALESCE(source_envelope->>'stage', '')) <> 'heartbeat' + AND received_at >= :window_start + ORDER BY received_at DESC + LIMIT :limit + """), + { + "project_id": safe_project_id, + "window_start": window_start, + "limit": _SOURCE_CORRELATION_EVENT_LIMIT, + }, + ) + source_rows = list(source_result.mappings().all()) + + heartbeat_result = await db.execute( + text(f""" + SELECT + {provider_sql} AS provider, + MAX(received_at) AS latest_heartbeat_at + FROM awooop_conversation_event + WHERE project_id = :project_id + AND {provider_sql} IN ('sentry', 'signoz') + AND LOWER(COALESCE(source_envelope->>'stage', '')) = 'heartbeat' + GROUP BY {provider_sql} + """), + {"project_id": safe_project_id}, + ) + heartbeat_rows = list(heartbeat_result.mappings().all()) + + if not incident_rows: + summary = _source_correlation_empty( + incident_ids, + status_value="no_incident_context", + missing_reason="incident_not_found", + ) + return summary + + contexts = [_incident_correlation_context(row) for row in incident_rows] + summary = _source_correlation_empty( + incident_ids, + status_value="missing", + missing_reason="no_matching_provider_source_event", + ) + providers = summary["providers"] + for heartbeat in heartbeat_rows: + provider = str(heartbeat.get("provider") or "").lower() + if provider in providers: + providers[provider]["latest_heartbeat_at"] = _iso_or_none( + heartbeat.get("latest_heartbeat_at") + ) + + top_candidates: list[dict[str, Any]] = [] + for row in source_rows: + event_context = _source_event_correlation_context(row) + provider = str(event_context.get("provider") or "").lower() + if provider not in providers: + continue + provider_item = providers[provider] + if provider_item.get("latest_event_at") is None: + provider_item["latest_event_at"] = _iso_or_none(row.get("received_at")) + + best_match: dict[str, Any] | None = None + for context in contexts: + scored = _score_source_correlation_event(context, event_context) + if best_match is None or scored["score"] > best_match["score"]: + best_match = scored + if not best_match or not best_match["is_candidate"]: + continue + + summary["provider_event_total"] += 1 + if best_match["is_direct"]: + summary["direct_ref_total"] += 1 + provider_item["direct_ref_total"] += 1 + else: + summary["candidate_total"] += 1 + provider_item["candidate_total"] += 1 + + top_candidates.append( + { + "provider": provider, + "provider_event_id": str(event_context.get("provider_event_id") or ""), + "stage": str(event_context.get("stage") or ""), + "score": best_match["score"], + "match_type": "direct" if best_match["is_direct"] else "candidate", + "reasons": best_match["reasons"], + "received_at": _iso_or_none(row.get("received_at")), + } + ) + + if summary["direct_ref_total"] > 0: + summary["status"] = "linked" + summary["missing_reason"] = None + elif summary["candidate_total"] > 0: + summary["status"] = "candidate_found" + summary["missing_reason"] = None + elif any(item.get("latest_heartbeat_at") for item in providers.values()): + summary["status"] = "provider_fresh_no_match" + summary["missing_reason"] = "provider_heartbeat_present_but_no_incident_match" + + summary["top_candidates"] = sorted( + top_candidates, + key=lambda item: (item.get("score") or 0, item.get("received_at") or ""), + reverse=True, + )[:5] + return summary + + 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): @@ -1377,6 +1782,7 @@ def _build_awooop_status_chain( remediation_history: dict[str, Any] | None = None, source_id: str | None = None, fetch_error: str | None = None, + source_correlation: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build the shared read-only status chain used by Telegram and Operator UI.""" truth_status = ( @@ -1450,6 +1856,8 @@ 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) + if source_correlation is not None: + source_section["correlation"] = source_correlation blockers = [ str(item) for item in [ @@ -1522,12 +1930,31 @@ async def _fetch_awooop_status_chain( error=fetch_error, ) + try: + source_correlation = await _fetch_source_correlation_summary( + incident_ids=incident_ids, + project_id=project_id or "awoooi", + ) + except Exception as exc: + logger.warning( + "operator_source_correlation_fetch_failed", + incident_ids=incident_ids, + project_id=project_id, + error=str(exc), + ) + source_correlation = _source_correlation_empty( + incident_ids, + status_value="fetch_failed", + missing_reason="source_correlation_fetch_failed", + ) + return _build_awooop_status_chain( incident_ids=incident_ids, truth_chain=truth_chain, remediation_history=remediation_history, source_id=source_id, fetch_error=fetch_error, + source_correlation=source_correlation, ) diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 36bd8a10..fdd5f443 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -32,6 +32,7 @@ from src.services.platform_operator_service import ( _remediation_timeline_summary, _run_callback_reply_summary, _run_remediation_list_summary, + _score_source_correlation_event, _timeline_sort_key, _validate_ai_route_workload, _validate_callback_reply_action_filter, @@ -604,6 +605,85 @@ def test_awooop_status_chain_marks_verified_repair() -> None: assert chain["source_refs"]["refs"]["signoz_alerts"] == ["signoz:abc"] +def test_awooop_status_chain_includes_source_provider_correlation() -> None: + chain = _build_awooop_status_chain( + incident_ids=["INC-20260520-4D1124"], + source_id="INC-20260520-4D1124", + source_correlation={ + "schema_version": "source_provider_correlation_v1", + "status": "candidate_found", + "direct_ref_total": 0, + "candidate_total": 2, + "provider_event_total": 2, + "providers": { + "sentry": {"direct_ref_total": 0, "candidate_total": 1}, + "signoz": {"direct_ref_total": 0, "candidate_total": 1}, + }, + "top_candidates": [ + { + "provider": "sentry", + "provider_event_id": "sentry:issue:1", + "score": 65, + "match_type": "candidate", + "reasons": ["alertname_overlap", "target_overlap"], + } + ], + }, + ) + + correlation = chain["source_refs"]["correlation"] + assert correlation["status"] == "candidate_found" + assert correlation["candidate_total"] == 2 + assert correlation["providers"]["sentry"]["candidate_total"] == 1 + assert correlation["top_candidates"][0]["provider_event_id"] == "sentry:issue:1" + + +def test_source_correlation_scoring_distinguishes_direct_and_candidate() -> None: + incident_context = { + "incident_ids": ["INC-20260520-4D1124"], + "alertnames": ["highcpuusage"], + "severities": ["p3"], + "fingerprints": ["fp-abc"], + "namespaces": ["awoooi-prod"], + "targets": ["api"], + } + direct_event = { + "incident_ids": ["INC-20260520-4D1124"], + "alertnames": ["other"], + "severities": [], + "fingerprints": [], + "namespaces": [], + "targets": [], + } + candidate_event = { + "incident_ids": [], + "alertnames": ["highcpuusage"], + "severities": ["p3"], + "fingerprints": [], + "namespaces": ["awoooi-prod"], + "targets": ["api"], + } + unrelated_event = { + "incident_ids": [], + "alertnames": ["configdrift"], + "severities": ["p3"], + "fingerprints": [], + "namespaces": ["awoooi-prod"], + "targets": [], + } + + direct = _score_source_correlation_event(incident_context, direct_event) + candidate = _score_source_correlation_event(incident_context, candidate_event) + unrelated = _score_source_correlation_event(incident_context, unrelated_event) + + assert direct["is_direct"] is True + assert direct["is_candidate"] is True + assert candidate["is_direct"] is False + assert candidate["is_candidate"] is True + assert "alertname_overlap" in candidate["reasons"] + assert unrelated["is_candidate"] is False + + def test_awooop_status_chain_marks_read_only_manual_gate() -> None: chain = _build_awooop_status_chain( incident_ids=["INC-20260513-79ED5E"], diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 29dbb33b..5f19d5de 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -436,7 +436,15 @@ "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}", + "flowSourceRefsDetail": "Source detail: Inbound {inbound} / Outbound {outbound}; Alert {alert}; Sentry {sentry}; SigNoz {signoz}; linked {linked} / candidate {candidate} ({correlation}); latest {latest}", + "flowSourceCorrelationStatus": { + "linked": "Directly linked", + "candidateFound": "Candidate found", + "providerFreshNoMatch": "Provider fresh, no match", + "missing": "No match yet", + "noIncidentContext": "Missing incident context", + "fetchFailed": "Read failed" + }, "flowTruthChainCurrent": "{stage} / {status}", "flowComplete": "Complete", "flowStages": { @@ -2494,6 +2502,20 @@ "mcp": "MCP", "km": "KM", "adr100": "ADR-100 Route" + }, + "source": { + "status": "Source Link", + "directCandidate": "Direct / Candidate", + "directCandidateValue": "{direct} / {candidate}", + "providers": "Provider", + "statuses": { + "linked": "Directly linked", + "candidateFound": "Candidate found", + "providerFreshNoMatch": "Provider fresh, no match", + "missing": "No match yet", + "noIncidentContext": "Missing incident context", + "fetchFailed": "Read failed" + } } }, "runDetail": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index a912767a..f470960f 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -437,7 +437,15 @@ "flowExecutionAnsibleConsidered": "已納入 ({records} records / {candidates} candidates)", "flowExecutionAnsibleNotUsed": "未使用:{reason}", "flowExecutionAnsibleEmpty": "--", - "flowSourceRefsDetail": "來源明細:Inbound {inbound} / Outbound {outbound};Alert {alert};Sentry {sentry};SigNoz {signoz};最新 {latest}", + "flowSourceRefsDetail": "來源明細:Inbound {inbound} / Outbound {outbound};Alert {alert};Sentry {sentry};SigNoz {signoz};關聯 {linked} / 候選 {candidate}({correlation});最新 {latest}", + "flowSourceCorrelationStatus": { + "linked": "已直接關聯", + "candidateFound": "找到候選", + "providerFreshNoMatch": "Provider 新鮮但未匹配", + "missing": "尚無匹配", + "noIncidentContext": "缺 Incident 脈絡", + "fetchFailed": "讀取失敗" + }, "flowTruthChainCurrent": "{stage} / {status}", "flowComplete": "已完成", "flowStages": { @@ -2495,6 +2503,20 @@ "mcp": "MCP", "km": "KM", "adr100": "ADR-100 Route" + }, + "source": { + "status": "來源關聯", + "directCandidate": "Direct / Candidate", + "directCandidateValue": "{direct} / {candidate}", + "providers": "Provider", + "statuses": { + "linked": "已直接關聯", + "candidateFound": "找到候選", + "providerFreshNoMatch": "Provider 新鮮但未匹配", + "missing": "尚無匹配", + "noIncidentContext": "缺 Incident 脈絡", + "fetchFailed": "讀取失敗" + } } }, "runDetail": { diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index 87c5ceb5..06c81aa1 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -99,6 +99,29 @@ export interface AwoooPStatusChain { fingerprints?: string[]; incident_ids?: string[]; }; + correlation?: { + schema_version?: string; + status?: string | null; + missing_reason?: string | null; + direct_ref_total?: number | null; + candidate_total?: number | null; + provider_event_total?: number | null; + providers?: Record; + top_candidates?: Array<{ + provider?: string | null; + provider_event_id?: string | null; + stage?: string | null; + score?: number | null; + match_type?: string | null; + reasons?: string[]; + received_at?: string | null; + }>; + }; latest_inbound?: { channel_type?: string | null; provider_event_id?: string | null; @@ -161,6 +184,7 @@ export function AwoooPStatusChainPanel({ const emptyLabel = t("emptyValue"); const evidence = chain?.evidence ?? {}; const blockers = chain?.blockers ?? []; + const sourceCorrelation = chain?.source_refs?.correlation; if (!chain) { return ( @@ -190,6 +214,19 @@ export function AwoooPStatusChainPanel({ { label: t("evidence.mcp"), value: evidence.mcp_gateway_total ?? 0 }, { label: t("evidence.km"), value: evidence.knowledge_entries ?? 0 }, ]; + const sourceStatusLabels: Record = { + linked: t("source.statuses.linked"), + candidate_found: t("source.statuses.candidateFound"), + provider_fresh_no_match: t("source.statuses.providerFreshNoMatch"), + missing: t("source.statuses.missing"), + no_incident_context: t("source.statuses.noIncidentContext"), + fetch_failed: t("source.statuses.fetchFailed"), + }; + const sourceStatus = String(sourceCorrelation?.status ?? "missing"); + const sourceProviderSummary = ["sentry", "signoz"].map((provider) => { + const providerItem = sourceCorrelation?.providers?.[provider]; + return `${provider} ${providerItem?.direct_ref_total ?? 0}/${providerItem?.candidate_total ?? 0}`; + }).join(" · "); return (
@@ -262,6 +299,32 @@ export function AwoooPStatusChainPanel({ + {sourceCorrelation && ( +
+
+

{t("source.status")}

+

+ {sourceStatusLabels[sourceStatus] ?? valueOrEmpty(sourceCorrelation.status, emptyLabel)} +

+
+
+

{t("source.directCandidate")}

+

+ {t("source.directCandidateValue", { + direct: sourceCorrelation.direct_ref_total ?? 0, + candidate: sourceCorrelation.candidate_total ?? 0, + })} +

+
+
+

{t("source.providers")}

+

+ {sourceProviderSummary} +

+
+
+ )} + {blockers.length > 0 && (
{t("blockers")}{" "} diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx index 6190105d..eb7b160c 100644 --- a/apps/web/src/components/incident/incident-card.tsx +++ b/apps/web/src/components/incident/incident-card.tsx @@ -58,6 +58,15 @@ const FLOW_STAGE_ORDER: FlowStage[] = [ 'resolved', ] +const SOURCE_CORRELATION_STATUS_KEYS = { + linked: 'linked', + candidate_found: 'candidateFound', + provider_fresh_no_match: 'providerFreshNoMatch', + missing: 'missing', + no_incident_context: 'noIncidentContext', + fetch_failed: 'fetchFailed', +} as const + /** 根據 incident + decision evidence 對應 FlowStage */ function toFlowStage(status: string, severity: string, decision?: DecisionInfo | null): FlowStage { const normalizedStatus = status.toLowerCase() @@ -381,6 +390,21 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange }) : null const sourceRefs = statusChain?.source_refs + const sourceCorrelation = sourceRefs?.correlation + const sourceCorrelationStatus = String(sourceCorrelation?.status ?? 'missing') + const sourceCorrelationKey = + SOURCE_CORRELATION_STATUS_KEYS[ + sourceCorrelationStatus as keyof typeof SOURCE_CORRELATION_STATUS_KEYS + ] ?? 'missing' + const sourceCorrelationStatusLabels: Record = { + linked: t('flowSourceCorrelationStatus.linked'), + candidateFound: t('flowSourceCorrelationStatus.candidateFound'), + providerFreshNoMatch: t('flowSourceCorrelationStatus.providerFreshNoMatch'), + missing: t('flowSourceCorrelationStatus.missing'), + noIncidentContext: t('flowSourceCorrelationStatus.noIncidentContext'), + fetchFailed: t('flowSourceCorrelationStatus.fetchFailed'), + } + const sourceCorrelationLabel = sourceCorrelationStatusLabels[sourceCorrelationKey] const latestSource = sourceRefs?.latest_inbound?.channel_type ? `${sourceRefs.latest_inbound.channel_type}/${chainValue(sourceRefs.latest_inbound.provider_event_id)}` : sourceRefs?.latest_outbound?.channel_type @@ -393,6 +417,9 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange alert: sourceRefs?.refs?.alert_ids?.length ?? 0, sentry: sourceRefs?.refs?.sentry_issue_ids?.length ?? 0, signoz: sourceRefs?.refs?.signoz_alerts?.length ?? 0, + linked: sourceCorrelation?.direct_ref_total ?? 0, + candidate: sourceCorrelation?.candidate_total ?? 0, + correlation: sourceCorrelationLabel, latest: latestSource, }) : null