diff --git a/apps/api/src/api/v1/platform/events.py b/apps/api/src/api/v1/platform/events.py index e4fd7f60..d8d7b41c 100644 --- a/apps/api/src/api/v1/platform/events.py +++ b/apps/api/src/api/v1/platform/events.py @@ -13,6 +13,7 @@ from uuid import UUID from fastapi import APIRouter, Query from pydantic import BaseModel +from src.services.channel_event_dossier_service import fetch_channel_event_dossier from src.services.platform_operator_service import list_recent_channel_events router = APIRouter() @@ -35,6 +36,70 @@ class RecentEventsResponse(BaseModel): limit: int +class ChannelEventDossierItem(BaseModel): + event_id: UUID + project_id: str + channel_type: str + provider: str | None + stage: str + provider_event_id: str + content_preview: str | None + content_redacted: str | None + has_redacted_content: bool + redaction_version: str | None + source_url: str | None + content_sha256: str | None + content_length: int | None + source_refs: dict[str, Any] + source_ref_count: int + log_correlation: dict[str, Any] + alertname: str | None + severity: str | None + namespace: str | None + target_resource: str | None + fingerprint: str | None + is_duplicate: bool + provider_ts: datetime | None + received_at: datetime + + +class ChannelEventDossierSummary(BaseModel): + source_count: int + duplicate_total: int + redacted_total: int + source_ref_total: int + + +class ChannelEventDossierResponse(BaseModel): + events: list[ChannelEventDossierItem] + total: int + limit: int + summary: ChannelEventDossierSummary + + +@router.get( + "/events/dossier", + response_model=ChannelEventDossierResponse, + summary="查詢 Channel Event 來源卷宗", + description=( + "返回 redacted inbound source envelope,供 AwoooP Run Detail 顯示" + "告警來源、source refs、Sentry / SignOz / Alertmanager 關聯與去重狀態。" + ), +) +async def get_event_dossier( + project_id: str | None = Query(None, description="租戶 ID(可選)"), + run_id: UUID | None = Query(None, description="Run ID(可選)"), + provider_event_id: str | None = Query(None, description="provider_event_id(可選)"), + limit: int = Query(20, ge=1, le=50, description="最多返回筆數"), +) -> dict[str, Any]: + return await fetch_channel_event_dossier( + project_id=project_id, + run_id=run_id, + provider_event_id=provider_event_id, + limit=limit, + ) + + @router.get( "/events/recent", response_model=RecentEventsResponse, diff --git a/apps/api/src/services/channel_event_dossier_service.py b/apps/api/src/services/channel_event_dossier_service.py new file mode 100644 index 00000000..75fedcd7 --- /dev/null +++ b/apps/api/src/services/channel_event_dossier_service.py @@ -0,0 +1,133 @@ +"""AwoooP inbound channel event dossier service. + +T15c: converts redacted inbound source envelopes into an Operator Console DTO. +The service is read-only and does not mutate incident, run, approval, or +automation state. +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy import text + +from src.db.base import get_db_context + +_MAX_DOSSIER_EVENTS = 50 + + +def _as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _compact_ref_count(source_refs: dict[str, Any]) -> int: + total = 0 + for value in source_refs.values(): + if isinstance(value, list): + total += len(value) + elif value: + total += 1 + return total + + +def build_dossier_event(row: dict[str, Any]) -> dict[str, Any]: + """Normalize a DB row into the front-end event dossier shape.""" + envelope = _as_dict(row.get("source_envelope")) + source_refs = _as_dict(envelope.get("source_refs")) + log_correlation = _as_dict(envelope.get("log_correlation")) + content_redacted = row.get("content_redacted") + content_preview = row.get("content_preview") + + return { + "event_id": row.get("event_id"), + "project_id": row.get("project_id"), + "channel_type": row.get("channel_type"), + "provider": envelope.get("provider") or row.get("channel_type"), + "stage": envelope.get("stage") or "received", + "provider_event_id": row.get("provider_event_id"), + "content_preview": content_preview, + "content_redacted": content_redacted, + "has_redacted_content": bool(content_redacted), + "redaction_version": row.get("redaction_version"), + "source_url": envelope.get("source_url"), + "content_sha256": envelope.get("content_sha256") or row.get("content_hash"), + "content_length": envelope.get("content_length"), + "source_refs": source_refs, + "source_ref_count": _compact_ref_count(source_refs), + "log_correlation": log_correlation, + "alertname": log_correlation.get("alertname"), + "severity": log_correlation.get("severity"), + "namespace": log_correlation.get("namespace"), + "target_resource": log_correlation.get("target_resource"), + "fingerprint": log_correlation.get("fingerprint"), + "is_duplicate": row.get("is_duplicate"), + "provider_ts": row.get("provider_ts"), + "received_at": row.get("received_at"), + } + + +async def fetch_channel_event_dossier( + *, + project_id: str | None, + run_id: UUID | None, + provider_event_id: str | None, + limit: int, +) -> dict[str, Any]: + """Fetch redacted source envelopes for a run or provider event id.""" + if run_id is None and not provider_event_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="run_id or provider_event_id is required", + ) + + effective_project_id = project_id or "awoooi" + safe_limit = max(1, min(limit, _MAX_DOSSIER_EVENTS)) + async with get_db_context(effective_project_id) as db: + result = await db.execute( + text(""" + SELECT + event_id, + project_id, + channel_type, + provider_event_id, + content_hash, + content_preview, + content_redacted, + redaction_version, + source_envelope, + is_duplicate, + provider_ts, + received_at + FROM awooop_conversation_event + WHERE project_id = :project_id + AND (:run_id IS NULL OR run_id = :run_id) + AND (:provider_event_id IS NULL OR provider_event_id = :provider_event_id) + ORDER BY received_at ASC + LIMIT :limit + """), + { + "project_id": effective_project_id, + "run_id": run_id, + "provider_event_id": provider_event_id, + "limit": safe_limit, + }, + ) + rows = [dict(row) for row in result.mappings().all()] + + events = [build_dossier_event(row) for row in rows] + duplicate_total = sum(1 for event in events if event.get("is_duplicate")) + redacted_total = sum(1 for event in events if event.get("has_redacted_content")) + + return { + "events": events, + "total": len(events), + "limit": safe_limit, + "summary": { + "source_count": len(events), + "duplicate_total": duplicate_total, + "redacted_total": redacted_total, + "source_ref_total": sum(int(event.get("source_ref_count") or 0) for event in events), + }, + } diff --git a/apps/api/tests/test_channel_event_dossier_service.py b/apps/api/tests/test_channel_event_dossier_service.py new file mode 100644 index 00000000..e98ccad3 --- /dev/null +++ b/apps/api/tests/test_channel_event_dossier_service.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from src.services.channel_event_dossier_service import ( + build_dossier_event, + fetch_channel_event_dossier, +) + + +def test_build_dossier_event_summarizes_source_envelope() -> None: + event = build_dossier_event({ + "event_id": "event-1", + "project_id": "awoooi", + "channel_type": "internal", + "provider_event_id": "sentry:received:issue-1", + "content_hash": "h" * 64, + "content_preview": "Sentry issue", + "content_redacted": "Sentry issue redacted", + "redaction_version": "audit_sink_v1", + "source_envelope": { + "provider": "sentry", + "stage": "received", + "source_url": "https://sentry.example.invalid/issues/issue-1", + "content_sha256": "a" * 64, + "content_length": 42, + "source_refs": { + "event_ids": ["issue-1"], + "sentry_issue_ids": ["issue-1", "sentry:received:issue-1"], + "fingerprints": ["sentry-issue-1"], + }, + "log_correlation": { + "alertname": "Sentry Issue", + "severity": "error", + "namespace": "sentry", + "target_resource": "frontend", + "fingerprint": "sentry-issue-1", + }, + }, + "is_duplicate": False, + "provider_ts": None, + "received_at": "2026-05-13T13:46:00", + }) + + assert event["provider"] == "sentry" + assert event["stage"] == "received" + assert event["alertname"] == "Sentry Issue" + assert event["severity"] == "error" + assert event["source_ref_count"] == 4 + assert event["has_redacted_content"] is True + assert event["content_sha256"] == "a" * 64 + + +@pytest.mark.asyncio +async def test_fetch_channel_event_dossier_requires_source() -> None: + with pytest.raises(HTTPException) as exc_info: + await fetch_channel_event_dossier( + project_id="awoooi", + run_id=None, + provider_event_id=None, + limit=20, + ) + + assert exc_info.value.status_code == 422 diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 6db95851..5f842b4a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1656,6 +1656,40 @@ "legacyBridge": "Legacy bridge" } }, + "dossier": { + "title": "Source Event Dossier", + "empty": "This run is not linked to replayable inbound source events yet.", + "content": "Redacted Content", + "sourceRefs": "Source References", + "duplicate": "Duplicate", + "firstSeen": "First seen", + "status": { + "visible": "Recorded in truth-chain", + "empty": "No source" + }, + "metrics": { + "sources": "Sources", + "refs": "References", + "redacted": "Redacted", + "duplicates": "Duplicates" + }, + "fields": { + "stage": "Stage", + "severity": "Risk", + "namespace": "Namespace", + "target": "Target", + "hash": "Hash" + }, + "refs": { + "alertIds": "Alert", + "approvalIds": "Approval", + "eventIds": "Event", + "fingerprints": "Fingerprint", + "incidentIds": "Incident", + "sentryIssueIds": "Sentry", + "signozAlerts": "SignOz" + } + }, "action": { "eyebrow": "Next Decision", "approval": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 25c12ed1..4b1266b5 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1657,6 +1657,40 @@ "legacyBridge": "Legacy bridge" } }, + "dossier": { + "title": "來源事件卷宗", + "empty": "此 Run 尚未連到可回放的入站來源事件。", + "content": "Redacted 內容", + "sourceRefs": "來源關聯", + "duplicate": "重複事件", + "firstSeen": "首次事件", + "status": { + "visible": "已寫入 truth-chain", + "empty": "尚無來源" + }, + "metrics": { + "sources": "來源事件", + "refs": "關聯索引", + "redacted": "Redacted", + "duplicates": "重複" + }, + "fields": { + "stage": "階段", + "severity": "風險", + "namespace": "命名空間", + "target": "目標", + "hash": "Hash" + }, + "refs": { + "alertIds": "Alert", + "approvalIds": "Approval", + "eventIds": "Event", + "fingerprints": "Fingerprint", + "incidentIds": "Incident", + "sentryIssueIds": "Sentry", + "signozAlerts": "SignOz" + } + }, "action": { "eyebrow": "下一步判斷", "approval": { diff --git a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx index e46f2bc7..24206b6b 100644 --- a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx @@ -15,6 +15,8 @@ import { ArrowRight, CheckCircle2, Clock, + FileSearch, + Link2, MessageSquareText, RefreshCw, Route, @@ -60,6 +62,43 @@ interface TimelineItem { metadata?: Record; } +interface ChannelEventDossierItem { + event_id: string; + project_id: string; + channel_type: string; + provider?: string | null; + stage: string; + provider_event_id: string; + content_preview?: string | null; + content_redacted?: string | null; + has_redacted_content: boolean; + redaction_version?: string | null; + source_url?: string | null; + content_sha256?: string | null; + content_length?: number | null; + source_refs: Record; + source_ref_count: number; + log_correlation: Record; + alertname?: string | null; + severity?: string | null; + namespace?: string | null; + target_resource?: string | null; + fingerprint?: string | null; + is_duplicate: boolean; + provider_ts?: string | null; + received_at: string; +} + +interface ChannelEventDossierResponse { + events: ChannelEventDossierItem[]; + summary: { + source_count: number; + duplicate_total: number; + redacted_total: number; + source_ref_total: number; + }; +} + interface McpGatewayBucket { agent_id?: string; tool_name?: string; @@ -136,6 +175,16 @@ const STATUS_TRANSLATION_KEYS: Record = { waiting_approval: "statuses.waitingApproval", }; +const SOURCE_REF_LABEL_KEYS: Record = { + alert_ids: "refs.alertIds", + approval_ids: "refs.approvalIds", + event_ids: "refs.eventIds", + fingerprints: "refs.fingerprints", + incident_ids: "refs.incidentIds", + sentry_issue_ids: "refs.sentryIssueIds", + signoz_alerts: "refs.signozAlerts", +}; + const MANUAL_STATES = new Set(["blocked", "cancelled", "error", "failed", "timeout"]); function formatTime(value: string | null | undefined, locale: string, emptyLabel: string) { @@ -162,6 +211,22 @@ function itemIcon(kind: string) { return Route; } +function firstSourceRefs(sourceRefs: Record, limit = 6) { + const refs: Array<{ key: string; value: string }> = []; + Object.entries(sourceRefs).forEach(([key, value]) => { + const values = Array.isArray(value) ? value : value ? [value] : []; + values.slice(0, 3).forEach((entry) => { + refs.push({ key, value: String(entry) }); + }); + }); + return refs.slice(0, limit); +} + +function shortDigest(value?: string | null) { + if (!value) return null; + return value.length > 16 ? `${value.slice(0, 12)}...${value.slice(-4)}` : value; +} + function RunActionPanel({ run, counts, @@ -268,6 +333,142 @@ function RunActionPanel({ ); } +function EventDossierPanel({ + dossier, + locale, + emptyLabel, +}: { + dossier?: ChannelEventDossierResponse | null; + locale: string; + emptyLabel: string; +}) { + const t = useTranslations("awooop.runDetail.dossier"); + const events = dossier?.events ?? []; + const summary = dossier?.summary; + const metrics = [ + { label: t("metrics.sources"), value: summary?.source_count ?? 0 }, + { label: t("metrics.refs"), value: summary?.source_ref_total ?? 0 }, + { label: t("metrics.redacted"), value: summary?.redacted_total ?? 0 }, + { label: t("metrics.duplicates"), value: summary?.duplicate_total ?? 0 }, + ]; + + return ( +
+
+
+
+ 0 ? statusClass("received") : statusClass("pending"))}> + {events.length > 0 ? t("status.visible") : t("status.empty")} + +
+
+ {metrics.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+ {events.length === 0 ? ( +
{t("empty")}
+ ) : ( +
+ {events.map((event) => { + const refs = firstSourceRefs(event.source_refs); + const digest = shortDigest(event.content_sha256); + return ( +
+
+
+
+ + {event.provider ?? event.channel_type} + + + {event.is_duplicate ? t("duplicate") : t("firstSeen")} + +
+

+ {event.alertname || event.provider_event_id} +

+

+ {formatTime(event.received_at, locale, emptyLabel)} +

+
+
+ + + + + +
+
+
+
+
{t("content")}
+

+ {event.content_redacted || event.content_preview || emptyLabel} +

+
+
+
{t("sourceRefs")}
+ {refs.length > 0 ? ( +
+ {refs.map((ref) => { + const labelKey = SOURCE_REF_LABEL_KEYS[ref.key]; + const label = labelKey ? t(labelKey as never) : ref.key; + return ( + + {label} + {ref.value} + + ); + })} +
+ ) : ( +

{emptyLabel}

+ )} +
+ {event.source_url && ( + + + )} +
+
+ ); + })} +
+ )} +
+ ); +} + +function DetailLine({ + label, + value, + emptyLabel, +}: { + label: string; + value?: string | number | null; + emptyLabel: string; +}) { + return ( +
+ {label} + {value ?? emptyLabel} +
+ ); +} + function DetailField({ label, value, @@ -427,6 +628,7 @@ export default function RunDetailPage({ const projectId = searchParams.get("project_id") ?? ""; const [detail, setDetail] = useState(null); + const [dossier, setDossier] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(new Date()); @@ -441,6 +643,17 @@ export default function RunDetailPage({ if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: RunDetailResponse = await res.json(); setDetail(data); + const dossierProjectId = projectId || data.run?.project_id; + const dossierQuery = new URLSearchParams(); + dossierQuery.set("run_id", run_id); + dossierQuery.set("limit", "20"); + if (dossierProjectId) dossierQuery.set("project_id", dossierProjectId); + try { + const dossierRes = await fetch(`${API_BASE}/api/v1/platform/events/dossier?${dossierQuery.toString()}`); + setDossier(dossierRes.ok ? await dossierRes.json() as ChannelEventDossierResponse : null); + } catch { + setDossier(null); + } setLastRefresh(new Date()); } catch (err) { setError(err instanceof Error ? err.message : t("errors.loadFailed")); @@ -552,6 +765,8 @@ export default function RunDetailPage({ statusLabel={statusLabel} /> + +