feat(awooop): show incident source correlation evidence
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 4m4s
CD Pipeline / build-and-deploy (push) Successful in 3m58s
CD Pipeline / post-deploy-checks (push) Successful in 1m55s

This commit is contained in:
Your Name
2026-05-20 20:19:36 +08:00
parent 26cab7a324
commit ef95d1ef6b
6 changed files with 645 additions and 4 deletions

View File

@@ -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,
)

View File

@@ -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"],

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<string, {
direct_ref_total?: number | null;
candidate_total?: number | null;
latest_event_at?: string | null;
latest_heartbeat_at?: string | null;
}>;
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<string, string> = {
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 (
<section className={cn("border border-[#e0ddd4] bg-white", className)}>
@@ -262,6 +299,32 @@ export function AwoooPStatusChainPanel({
</div>
</div>
{sourceCorrelation && (
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-3">
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("source.status")}</p>
<p className="mt-2 truncate font-mono text-sm text-[#141413]" title={sourceStatusLabels[sourceStatus] ?? sourceStatus}>
{sourceStatusLabels[sourceStatus] ?? valueOrEmpty(sourceCorrelation.status, emptyLabel)}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("source.directCandidate")}</p>
<p className="mt-2 font-mono text-sm text-[#141413]">
{t("source.directCandidateValue", {
direct: sourceCorrelation.direct_ref_total ?? 0,
candidate: sourceCorrelation.candidate_total ?? 0,
})}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("source.providers")}</p>
<p className="mt-2 truncate font-mono text-sm text-[#141413]" title={sourceProviderSummary}>
{sourceProviderSummary}
</p>
</div>
</div>
)}
{blockers.length > 0 && (
<div className="border-t border-[#eee9dd] bg-[#fff7e8] px-4 py-3 text-xs leading-5 text-[#8a5a08]">
<span className="font-semibold">{t("blockers")}</span>{" "}

View File

@@ -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<typeof sourceCorrelationKey, string> = {
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