feat(awooop): surface source dossier coverage
This commit is contained in:
@@ -13,7 +13,10 @@ 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.channel_event_dossier_service import (
|
||||
fetch_channel_event_dossier,
|
||||
fetch_channel_event_dossier_coverage,
|
||||
)
|
||||
from src.services.platform_operator_service import list_recent_channel_events
|
||||
|
||||
router = APIRouter()
|
||||
@@ -77,6 +80,41 @@ class ChannelEventDossierResponse(BaseModel):
|
||||
summary: ChannelEventDossierSummary
|
||||
|
||||
|
||||
class ChannelEventProviderCoverage(BaseModel):
|
||||
provider: str
|
||||
total: int
|
||||
duplicate_total: int
|
||||
redacted_total: int
|
||||
source_ref_total: int
|
||||
missing_source_refs_total: int
|
||||
sentry_ref_total: int
|
||||
signoz_ref_total: int
|
||||
alert_ref_total: int
|
||||
latest_received_at: datetime | None
|
||||
|
||||
|
||||
class ChannelEventDossierCoverageSummary(BaseModel):
|
||||
source_count: int
|
||||
source_envelope_total: int
|
||||
missing_source_envelope_total: int
|
||||
with_source_refs_total: int
|
||||
missing_source_refs_total: int
|
||||
duplicate_total: int
|
||||
redacted_total: int
|
||||
source_ref_total: int
|
||||
sentry_ref_total: int
|
||||
signoz_ref_total: int
|
||||
alert_ref_total: int
|
||||
latest_received_at: datetime | None
|
||||
|
||||
|
||||
class ChannelEventDossierCoverageResponse(BaseModel):
|
||||
project_id: str
|
||||
limit: int
|
||||
summary: ChannelEventDossierCoverageSummary
|
||||
providers: list[ChannelEventProviderCoverage]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/dossier",
|
||||
response_model=ChannelEventDossierResponse,
|
||||
@@ -100,6 +138,27 @@ async def get_event_dossier(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/dossier/coverage",
|
||||
response_model=ChannelEventDossierCoverageResponse,
|
||||
summary="查詢 Channel Event 來源卷宗覆蓋率",
|
||||
description=(
|
||||
"返回近期 inbound event 的 source_envelope / source_refs / 去重 / "
|
||||
"Sentry / SignOz 關聯覆蓋率,供 AwoooP Run List 顯示告警是否已入庫。"
|
||||
),
|
||||
)
|
||||
async def get_event_dossier_coverage(
|
||||
project_id: str | None = Query(None, description="租戶 ID(可選)"),
|
||||
provider: str | None = Query(None, description="provider(可選,如 sentry / signoz)"),
|
||||
limit: int = Query(100, ge=1, le=200, description="最多納入統計筆數"),
|
||||
) -> dict[str, Any]:
|
||||
return await fetch_channel_event_dossier_coverage(
|
||||
project_id=project_id,
|
||||
provider=provider,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/recent",
|
||||
response_model=RecentEventsResponse,
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import text
|
||||
from src.db.base import get_db_context
|
||||
|
||||
_MAX_DOSSIER_EVENTS = 50
|
||||
_MAX_COVERAGE_EVENTS = 200
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any]:
|
||||
@@ -32,6 +33,106 @@ def _compact_ref_count(source_refs: dict[str, Any]) -> int:
|
||||
return total
|
||||
|
||||
|
||||
def _ref_count(source_refs: dict[str, Any], key: str) -> int:
|
||||
value = source_refs.get(key)
|
||||
if isinstance(value, list):
|
||||
return len(value)
|
||||
return 1 if value else 0
|
||||
|
||||
|
||||
def build_dossier_coverage(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
project_id: str,
|
||||
limit: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize recent inbound source envelopes for console coverage checks."""
|
||||
events = [build_dossier_event(row) for row in rows]
|
||||
provider_map: dict[str, dict[str, Any]] = {}
|
||||
source_envelope_total = 0
|
||||
with_source_refs_total = 0
|
||||
sentry_ref_total = 0
|
||||
signoz_ref_total = 0
|
||||
alert_ref_total = 0
|
||||
|
||||
for row, event in zip(rows, events, strict=False):
|
||||
envelope = _as_dict(row.get("source_envelope"))
|
||||
if envelope:
|
||||
source_envelope_total += 1
|
||||
|
||||
source_refs = _as_dict(event.get("source_refs"))
|
||||
source_ref_count = int(event.get("source_ref_count") or 0)
|
||||
if source_ref_count > 0:
|
||||
with_source_refs_total += 1
|
||||
|
||||
provider = str(event.get("provider") or row.get("channel_type") or "unknown")
|
||||
provider_item = provider_map.setdefault(
|
||||
provider,
|
||||
{
|
||||
"provider": provider,
|
||||
"total": 0,
|
||||
"duplicate_total": 0,
|
||||
"redacted_total": 0,
|
||||
"source_ref_total": 0,
|
||||
"missing_source_refs_total": 0,
|
||||
"sentry_ref_total": 0,
|
||||
"signoz_ref_total": 0,
|
||||
"alert_ref_total": 0,
|
||||
"latest_received_at": None,
|
||||
},
|
||||
)
|
||||
provider_item["total"] += 1
|
||||
provider_item["source_ref_total"] += source_ref_count
|
||||
if event.get("is_duplicate"):
|
||||
provider_item["duplicate_total"] += 1
|
||||
if event.get("has_redacted_content"):
|
||||
provider_item["redacted_total"] += 1
|
||||
if source_ref_count <= 0:
|
||||
provider_item["missing_source_refs_total"] += 1
|
||||
|
||||
event_sentry_refs = _ref_count(source_refs, "sentry_issue_ids")
|
||||
event_signoz_refs = _ref_count(source_refs, "signoz_alerts")
|
||||
event_alert_refs = _ref_count(source_refs, "alert_ids")
|
||||
sentry_ref_total += event_sentry_refs
|
||||
signoz_ref_total += event_signoz_refs
|
||||
alert_ref_total += event_alert_refs
|
||||
provider_item["sentry_ref_total"] += event_sentry_refs
|
||||
provider_item["signoz_ref_total"] += event_signoz_refs
|
||||
provider_item["alert_ref_total"] += event_alert_refs
|
||||
provider_item["latest_received_at"] = (
|
||||
provider_item["latest_received_at"] or event.get("received_at")
|
||||
)
|
||||
|
||||
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"))
|
||||
source_ref_total = sum(int(event.get("source_ref_count") or 0) for event in events)
|
||||
missing_source_refs_total = len(events) - with_source_refs_total
|
||||
missing_source_envelope_total = len(events) - source_envelope_total
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"limit": limit,
|
||||
"summary": {
|
||||
"source_count": len(events),
|
||||
"source_envelope_total": source_envelope_total,
|
||||
"missing_source_envelope_total": missing_source_envelope_total,
|
||||
"with_source_refs_total": with_source_refs_total,
|
||||
"missing_source_refs_total": missing_source_refs_total,
|
||||
"duplicate_total": duplicate_total,
|
||||
"redacted_total": redacted_total,
|
||||
"source_ref_total": source_ref_total,
|
||||
"sentry_ref_total": sentry_ref_total,
|
||||
"signoz_ref_total": signoz_ref_total,
|
||||
"alert_ref_total": alert_ref_total,
|
||||
"latest_received_at": events[0].get("received_at") if events else None,
|
||||
},
|
||||
"providers": sorted(
|
||||
provider_map.values(),
|
||||
key=lambda item: (-int(item.get("total") or 0), str(item.get("provider") or "")),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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"))
|
||||
@@ -136,3 +237,56 @@ async def fetch_channel_event_dossier(
|
||||
"source_ref_total": sum(int(event.get("source_ref_count") or 0) for event in events),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def fetch_channel_event_dossier_coverage(
|
||||
*,
|
||||
project_id: str | None,
|
||||
provider: str | None,
|
||||
limit: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch a read-only coverage summary for recent inbound channel events."""
|
||||
effective_project_id = project_id or "awoooi"
|
||||
safe_limit = max(1, min(limit, _MAX_COVERAGE_EVENTS))
|
||||
where_clauses = ["project_id = :project_id"]
|
||||
params: dict[str, Any] = {
|
||||
"project_id": effective_project_id,
|
||||
"limit": safe_limit,
|
||||
}
|
||||
if provider:
|
||||
where_clauses.append(
|
||||
"COALESCE(NULLIF(source_envelope->>'provider', ''), "
|
||||
"split_part(provider_event_id, ':', 1), channel_type) = :provider"
|
||||
)
|
||||
params["provider"] = provider
|
||||
|
||||
async with get_db_context(effective_project_id) as db:
|
||||
result = await db.execute(
|
||||
text(f"""
|
||||
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 {" AND ".join(where_clauses)}
|
||||
ORDER BY received_at DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
params,
|
||||
)
|
||||
rows = [dict(row) for row in result.mappings().all()]
|
||||
|
||||
return build_dossier_coverage(
|
||||
rows,
|
||||
project_id=effective_project_id,
|
||||
limit=safe_limit,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,9 @@ from uuid import UUID
|
||||
from src.services import channel_event_dossier_service
|
||||
from src.services.channel_event_dossier_service import (
|
||||
build_dossier_event,
|
||||
build_dossier_coverage,
|
||||
fetch_channel_event_dossier,
|
||||
fetch_channel_event_dossier_coverage,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +56,85 @@ def test_build_dossier_event_summarizes_source_envelope() -> None:
|
||||
assert event["content_sha256"] == "a" * 64
|
||||
|
||||
|
||||
def test_build_dossier_coverage_summarizes_recent_sources() -> None:
|
||||
coverage = build_dossier_coverage(
|
||||
[
|
||||
{
|
||||
"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_refs": {
|
||||
"sentry_issue_ids": ["issue-1"],
|
||||
"alert_ids": ["sentry:received:issue-1"],
|
||||
"fingerprints": ["fingerprint-1"],
|
||||
},
|
||||
},
|
||||
"is_duplicate": False,
|
||||
"provider_ts": None,
|
||||
"received_at": "2026-05-13T13:46:00",
|
||||
},
|
||||
{
|
||||
"event_id": "event-2",
|
||||
"project_id": "awoooi",
|
||||
"channel_type": "internal",
|
||||
"provider_event_id": "signoz:received:alert-1",
|
||||
"content_hash": "i" * 64,
|
||||
"content_preview": "SignOz alert",
|
||||
"content_redacted": None,
|
||||
"redaction_version": "audit_sink_v1",
|
||||
"source_envelope": {
|
||||
"provider": "signoz",
|
||||
"stage": "received",
|
||||
"source_refs": {
|
||||
"signoz_alerts": ["alert-1"],
|
||||
"alert_ids": ["signoz:received:alert-1"],
|
||||
},
|
||||
},
|
||||
"is_duplicate": True,
|
||||
"provider_ts": None,
|
||||
"received_at": "2026-05-13T13:45:00",
|
||||
},
|
||||
{
|
||||
"event_id": "event-3",
|
||||
"project_id": "awoooi",
|
||||
"channel_type": "telegram",
|
||||
"provider_event_id": "telegram:callback:1",
|
||||
"content_hash": None,
|
||||
"content_preview": "Callback",
|
||||
"content_redacted": None,
|
||||
"redaction_version": "audit_sink_v1",
|
||||
"source_envelope": {},
|
||||
"is_duplicate": False,
|
||||
"provider_ts": None,
|
||||
"received_at": "2026-05-13T13:44:00",
|
||||
},
|
||||
],
|
||||
project_id="awoooi",
|
||||
limit=100,
|
||||
)
|
||||
|
||||
assert coverage["project_id"] == "awoooi"
|
||||
assert coverage["summary"]["source_count"] == 3
|
||||
assert coverage["summary"]["source_envelope_total"] == 2
|
||||
assert coverage["summary"]["missing_source_envelope_total"] == 1
|
||||
assert coverage["summary"]["with_source_refs_total"] == 2
|
||||
assert coverage["summary"]["missing_source_refs_total"] == 1
|
||||
assert coverage["summary"]["duplicate_total"] == 1
|
||||
assert coverage["summary"]["redacted_total"] == 1
|
||||
assert coverage["summary"]["sentry_ref_total"] == 1
|
||||
assert coverage["summary"]["signoz_ref_total"] == 1
|
||||
assert coverage["summary"]["alert_ref_total"] == 2
|
||||
assert coverage["providers"][0]["provider"] == "sentry"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_channel_event_dossier_requires_source() -> None:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -114,3 +195,50 @@ async def test_fetch_channel_event_dossier_uses_typed_run_filter(monkeypatch) ->
|
||||
"run_id": str(run_id),
|
||||
"limit": 20,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeMappings:
|
||||
def all(self) -> list[dict[str, object]]:
|
||||
return []
|
||||
|
||||
class FakeResult:
|
||||
def mappings(self) -> FakeMappings:
|
||||
return FakeMappings()
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, statement, params): # noqa: ANN001
|
||||
captured["sql"] = str(statement)
|
||||
captured["params"] = params
|
||||
return FakeResult()
|
||||
|
||||
class FakeContext:
|
||||
async def __aenter__(self) -> FakeDb:
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
channel_event_dossier_service,
|
||||
"get_db_context",
|
||||
lambda _project_id: FakeContext(),
|
||||
)
|
||||
|
||||
result = await fetch_channel_event_dossier_coverage(
|
||||
project_id=None,
|
||||
provider="sentry",
|
||||
limit=500,
|
||||
)
|
||||
|
||||
assert result["project_id"] == "awoooi"
|
||||
assert result["limit"] == 200
|
||||
assert "source_envelope->>'provider'" in str(captured["sql"])
|
||||
assert captured["params"] == {
|
||||
"project_id": "awoooi",
|
||||
"provider": "sentry",
|
||||
"limit": 200,
|
||||
}
|
||||
|
||||
@@ -1871,6 +1871,36 @@
|
||||
"approvalNoEvidenceDetail": "Approval still lacks AI evidence; inspect Run Timeline"
|
||||
}
|
||||
},
|
||||
"sourceDossierCoverage": {
|
||||
"title": "Source Dossier Coverage",
|
||||
"subtitle": "Inbound alert dossiers, dedupe, and Sentry / SignOz references",
|
||||
"total": "{count} items",
|
||||
"empty": "No recent source event dossiers.",
|
||||
"error": "Source dossier coverage failed to load: {error}",
|
||||
"metrics": {
|
||||
"sources": "Source events",
|
||||
"refs": "Reference index",
|
||||
"missingRefs": "Missing refs",
|
||||
"duplicates": "Duplicate events",
|
||||
"sentry": "Sentry refs",
|
||||
"signoz": "SignOz refs"
|
||||
},
|
||||
"details": {
|
||||
"latest": "Latest {time}",
|
||||
"withRefs": "{count} items with source refs",
|
||||
"missingEnvelope": "{count} items missing source envelope",
|
||||
"redacted": "{count} items redacted",
|
||||
"alertRefs": "{count} alert refs",
|
||||
"limit": "Latest {count} item window"
|
||||
},
|
||||
"provider": {
|
||||
"latest": "Latest {time}",
|
||||
"refs": "Refs {count}",
|
||||
"missing": "Missing {count}",
|
||||
"redacted": "Redacted {count}",
|
||||
"duplicates": "Duplicates {count}"
|
||||
}
|
||||
},
|
||||
"callbackReply": {
|
||||
"count": "{total} items; fallback {fallback}; failed {failed}",
|
||||
"emptyShort": "No detail / history callback yet",
|
||||
|
||||
@@ -1872,6 +1872,36 @@
|
||||
"approvalNoEvidenceDetail": "審批前仍缺 AI 證據,需進 Run Timeline 檢查"
|
||||
}
|
||||
},
|
||||
"sourceDossierCoverage": {
|
||||
"title": "來源事件覆蓋率",
|
||||
"subtitle": "入站告警卷宗、去重與 Sentry / SignOz 關聯狀態",
|
||||
"total": "{count} 筆",
|
||||
"empty": "目前沒有近期來源事件卷宗。",
|
||||
"error": "來源卷宗覆蓋率載入失敗:{error}",
|
||||
"metrics": {
|
||||
"sources": "來源事件",
|
||||
"refs": "關聯索引",
|
||||
"missingRefs": "缺關聯",
|
||||
"duplicates": "重複事件",
|
||||
"sentry": "Sentry refs",
|
||||
"signoz": "SignOz refs"
|
||||
},
|
||||
"details": {
|
||||
"latest": "最新 {time}",
|
||||
"withRefs": "{count} 筆含 source refs",
|
||||
"missingEnvelope": "{count} 筆缺 source envelope",
|
||||
"redacted": "{count} 筆已 redacted",
|
||||
"alertRefs": "{count} 個 alert refs",
|
||||
"limit": "最近 {count} 筆視窗"
|
||||
},
|
||||
"provider": {
|
||||
"latest": "最新 {time}",
|
||||
"refs": "Refs {count}",
|
||||
"missing": "缺 {count}",
|
||||
"redacted": "Redacted {count}",
|
||||
"duplicates": "重複 {count}"
|
||||
}
|
||||
},
|
||||
"callbackReply": {
|
||||
"count": "{total} 筆;fallback {fallback};失敗 {failed}",
|
||||
"emptyShort": "尚無詳情 / 歷史 callback",
|
||||
|
||||
@@ -141,6 +141,41 @@ interface RecentEventsResponse {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface DossierCoverageSummary {
|
||||
source_count: number;
|
||||
source_envelope_total: number;
|
||||
missing_source_envelope_total: number;
|
||||
with_source_refs_total: number;
|
||||
missing_source_refs_total: number;
|
||||
duplicate_total: number;
|
||||
redacted_total: number;
|
||||
source_ref_total: number;
|
||||
sentry_ref_total: number;
|
||||
signoz_ref_total: number;
|
||||
alert_ref_total: number;
|
||||
latest_received_at?: string | null;
|
||||
}
|
||||
|
||||
interface DossierCoverageProvider {
|
||||
provider: string;
|
||||
total: number;
|
||||
duplicate_total: number;
|
||||
redacted_total: number;
|
||||
source_ref_total: number;
|
||||
missing_source_refs_total: number;
|
||||
sentry_ref_total: number;
|
||||
signoz_ref_total: number;
|
||||
alert_ref_total: number;
|
||||
latest_received_at?: string | null;
|
||||
}
|
||||
|
||||
interface DossierCoverageResponse {
|
||||
project_id: string;
|
||||
limit: number;
|
||||
summary: DossierCoverageSummary;
|
||||
providers: DossierCoverageProvider[];
|
||||
}
|
||||
|
||||
interface CallbackReplyEvent {
|
||||
message_id: string;
|
||||
run_id: string;
|
||||
@@ -700,6 +735,155 @@ function RunRow({ run }: { run: Run }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SourceDossierCoveragePanel({
|
||||
coverage,
|
||||
error,
|
||||
}: {
|
||||
coverage: DossierCoverageResponse | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
const t = useTranslations("awooop.sourceDossierCoverage");
|
||||
const summary = coverage?.summary;
|
||||
const providers = coverage?.providers ?? [];
|
||||
const latestAt = summary?.latest_received_at
|
||||
? new Date(summary.latest_received_at).toLocaleTimeString("zh-TW", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--";
|
||||
const metrics = [
|
||||
{
|
||||
label: t("metrics.sources"),
|
||||
value: summary?.source_count ?? 0,
|
||||
detail: t("details.latest", { time: latestAt }),
|
||||
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
},
|
||||
{
|
||||
label: t("metrics.refs"),
|
||||
value: summary?.source_ref_total ?? 0,
|
||||
detail: t("details.withRefs", { count: summary?.with_source_refs_total ?? 0 }),
|
||||
className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
|
||||
},
|
||||
{
|
||||
label: t("metrics.missingRefs"),
|
||||
value: summary?.missing_source_refs_total ?? 0,
|
||||
detail: t("details.missingEnvelope", {
|
||||
count: summary?.missing_source_envelope_total ?? 0,
|
||||
}),
|
||||
className: (summary?.missing_source_refs_total ?? 0) > 0
|
||||
? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
|
||||
: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
|
||||
},
|
||||
{
|
||||
label: t("metrics.duplicates"),
|
||||
value: summary?.duplicate_total ?? 0,
|
||||
detail: t("details.redacted", { count: summary?.redacted_total ?? 0 }),
|
||||
className: "border-[#d8d3c7] bg-white text-[#5f5b52]",
|
||||
},
|
||||
{
|
||||
label: t("metrics.sentry"),
|
||||
value: summary?.sentry_ref_total ?? 0,
|
||||
detail: t("details.alertRefs", { count: summary?.alert_ref_total ?? 0 }),
|
||||
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
},
|
||||
{
|
||||
label: t("metrics.signoz"),
|
||||
value: summary?.signoz_ref_total ?? 0,
|
||||
detail: t("details.limit", { count: coverage?.limit ?? 0 }),
|
||||
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="h-4 w-4 text-[#1f5b9b]" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
|
||||
<p className="text-xs text-[#77736a]">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="border border-[#9bb6d9] bg-[#eef5ff] px-2 py-0.5 text-xs font-semibold text-[#1f5b9b]">
|
||||
{t("total", { count: summary?.source_count ?? 0 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="px-4 py-4 text-sm text-[#9f2f25]">
|
||||
{t("error", { error })}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-3 xl:grid-cols-6">
|
||||
{metrics.map((item) => (
|
||||
<div key={item.label} className="bg-white px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
|
||||
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center border",
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
<SearchCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">{item.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-4 py-4 text-sm text-[#5f5b52]">
|
||||
{t("empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2 xl:grid-cols-4">
|
||||
{providers.slice(0, 4).map((provider) => {
|
||||
const providerLatest = provider.latest_received_at
|
||||
? new Date(provider.latest_received_at).toLocaleTimeString("zh-TW", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--";
|
||||
return (
|
||||
<article key={provider.provider} className="bg-white px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-xs font-semibold text-[#141413]">
|
||||
{provider.provider}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#77736a]">
|
||||
{t("provider.latest", { time: providerLatest })}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 font-mono text-xs text-[#5f5b52]">
|
||||
{provider.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs leading-5 text-[#5f5b52]">
|
||||
<p>{t("provider.refs", { count: provider.source_ref_total })}</p>
|
||||
<p>{t("provider.missing", { count: provider.missing_source_refs_total })}</p>
|
||||
<p>{t("provider.redacted", { count: provider.redacted_total })}</p>
|
||||
<p>{t("provider.duplicates", { count: provider.duplicate_total })}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedAlertEventsPanel({ events }: { events: PlatformEvent[] }) {
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
@@ -867,6 +1051,8 @@ export default function RunsPage() {
|
||||
const tCallback = useTranslations("awooop.callbackReply");
|
||||
const [runs, setRuns] = useState<Run[]>([]);
|
||||
const [groupedEvents, setGroupedEvents] = useState<PlatformEvent[]>([]);
|
||||
const [dossierCoverage, setDossierCoverage] = useState<DossierCoverageResponse | null>(null);
|
||||
const [dossierCoverageError, setDossierCoverageError] = useState<string | null>(null);
|
||||
const [callbackEvents, setCallbackEvents] = useState<CallbackReplyEvent[]>([]);
|
||||
const [callbackEventsTotal, setCallbackEventsTotal] = useState(0);
|
||||
const [callbackEventsError, setCallbackEventsError] = useState<string | null>(null);
|
||||
@@ -949,6 +1135,21 @@ export default function RunsPage() {
|
||||
setGroupedEvents(Array.isArray(eventsData.events) ? eventsData.events : []);
|
||||
}
|
||||
|
||||
const dossierCoverageParams = new URLSearchParams();
|
||||
dossierCoverageParams.set("limit", "100");
|
||||
if (projectFilter) dossierCoverageParams.set("project_id", projectFilter);
|
||||
const dossierCoverageRes = await fetch(
|
||||
`${API_BASE}/api/v1/platform/events/dossier/coverage?${dossierCoverageParams.toString()}`
|
||||
);
|
||||
if (dossierCoverageRes.ok) {
|
||||
const dossierCoverageData: DossierCoverageResponse = await dossierCoverageRes.json();
|
||||
setDossierCoverage(dossierCoverageData);
|
||||
setDossierCoverageError(null);
|
||||
} else {
|
||||
setDossierCoverage(null);
|
||||
setDossierCoverageError(`HTTP ${dossierCoverageRes.status}`);
|
||||
}
|
||||
|
||||
const callbackParams = new URLSearchParams();
|
||||
callbackParams.set("per_page", "6");
|
||||
if (projectFilter) callbackParams.set("project_id", projectFilter);
|
||||
@@ -1161,6 +1362,11 @@ export default function RunsPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
<SourceDossierCoveragePanel
|
||||
coverage={dossierCoverage}
|
||||
error={dossierCoverageError}
|
||||
/>
|
||||
|
||||
<GroupedAlertEventsPanel events={groupedEvents} />
|
||||
|
||||
<CallbackReplyEvidencePanel
|
||||
|
||||
Reference in New Issue
Block a user