From 94f8c68b779c90859f9aebbb62b043231b22b0d7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 18 May 2026 19:23:37 +0800 Subject: [PATCH] feat(awooop): show recurring alert links --- apps/api/src/api/v1/platform/events.py | 66 +++++ .../services/channel_event_dossier_service.py | 189 ++++++++++++++ .../test_channel_event_dossier_service.py | 157 ++++++++++++ apps/web/messages/en.json | 38 +++ apps/web/messages/zh-TW.json | 38 +++ .../web/src/app/[locale]/awooop/runs/page.tsx | 239 ++++++++++++++++++ 6 files changed, 727 insertions(+) diff --git a/apps/api/src/api/v1/platform/events.py b/apps/api/src/api/v1/platform/events.py index 9c5284dc..ef36f75d 100644 --- a/apps/api/src/api/v1/platform/events.py +++ b/apps/api/src/api/v1/platform/events.py @@ -16,6 +16,7 @@ from pydantic import BaseModel from src.services.channel_event_dossier_service import ( fetch_channel_event_dossier, fetch_channel_event_dossier_coverage, + fetch_channel_event_dossier_recurrence, ) from src.services.platform_operator_service import list_recent_channel_events @@ -115,6 +116,50 @@ class ChannelEventDossierCoverageResponse(BaseModel): providers: list[ChannelEventProviderCoverage] +class ChannelEventRecurrenceSummary(BaseModel): + source_event_total: int + recurrence_group_total: int + recurrent_group_total: int + duplicate_event_total: int + linked_run_total: int + unlinked_event_total: int + latest_received_at: datetime | None + + +class ChannelEventRecurrenceItem(BaseModel): + recurrence_key: str + provider: str | None + alertname: str | None + severity: str | None + namespace: str | None + target_resource: str | None + fingerprint: str | None + latest_event_id: UUID | None + latest_provider_event_id: str | None + latest_content_preview: str | None + latest_run_id: UUID | None + latest_run_state: str | None + latest_agent_id: str | None + occurrence_total: int + duplicate_total: int + linked_run_total: int + source_ref_total: int + missing_source_refs_total: int + sentry_ref_total: int + signoz_ref_total: int + alert_ref_total: int + run_state_counts: dict[str, int] + first_received_at: datetime | None + latest_received_at: datetime | None + + +class ChannelEventRecurrenceResponse(BaseModel): + project_id: str + limit: int + summary: ChannelEventRecurrenceSummary + items: list[ChannelEventRecurrenceItem] + + @router.get( "/events/dossier", response_model=ChannelEventDossierResponse, @@ -159,6 +204,27 @@ async def get_event_dossier_coverage( ) +@router.get( + "/events/dossier/recurrence", + response_model=ChannelEventRecurrenceResponse, + summary="查詢 Channel Event 重複發生與關聯 Run 狀態", + description=( + "將近期 inbound source events 依 fingerprint / alertname / namespace / target 分組," + "顯示重複發生次數、去重數、source refs 與最新 linked run 狀態。" + ), +) +async def get_event_dossier_recurrence( + project_id: str | None = Query(None, description="租戶 ID(可選)"), + provider: str | None = Query(None, description="provider(可選,如 alertmanager / sentry / signoz)"), + limit: int = Query(100, ge=1, le=300, description="最多納入統計筆數"), +) -> dict[str, Any]: + return await fetch_channel_event_dossier_recurrence( + project_id=project_id, + provider=provider, + 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 index 2b9c8069..d855f324 100644 --- a/apps/api/src/services/channel_event_dossier_service.py +++ b/apps/api/src/services/channel_event_dossier_service.py @@ -17,6 +17,7 @@ from src.db.base import get_db_context _MAX_DOSSIER_EVENTS = 50 _MAX_COVERAGE_EVENTS = 200 +_MAX_RECURRENCE_EVENTS = 300 def _as_dict(value: Any) -> dict[str, Any]: @@ -40,6 +41,135 @@ def _ref_count(source_refs: dict[str, Any], key: str) -> int: return 1 if value else 0 +def _recurrence_key(event: dict[str, Any]) -> str: + fingerprint = str(event.get("fingerprint") or "").strip() + if fingerprint: + return f"fingerprint:{fingerprint}" + + provider = str(event.get("provider") or event.get("channel_type") or "unknown") + alertname = str(event.get("alertname") or "").strip() + namespace = str(event.get("namespace") or "").strip() + target = str(event.get("target_resource") or "").strip() + if alertname or namespace or target: + return f"alert:{provider}:{alertname}:{namespace}:{target}" + + return f"event:{provider}:{event.get('provider_event_id')}" + + +def build_dossier_recurrence( + rows: list[dict[str, Any]], + *, + project_id: str, + limit: int, +) -> dict[str, Any]: + """Group recent source events into recurrence buckets with linked run state.""" + groups: dict[str, dict[str, Any]] = {} + + for row in rows: + event = build_dossier_event(row) + key = _recurrence_key(event) + source_ref_count = int(event.get("source_ref_count") or 0) + source_refs = _as_dict(event.get("source_refs")) + run_id = row.get("run_id") + run_state = row.get("run_state") + received_at = event.get("received_at") + + group = groups.setdefault( + key, + { + "recurrence_key": key, + "provider": event.get("provider"), + "alertname": event.get("alertname"), + "severity": event.get("severity"), + "namespace": event.get("namespace"), + "target_resource": event.get("target_resource"), + "fingerprint": event.get("fingerprint"), + "latest_event_id": event.get("event_id"), + "latest_provider_event_id": event.get("provider_event_id"), + "latest_content_preview": event.get("content_preview"), + "latest_run_id": run_id, + "latest_run_state": run_state, + "latest_agent_id": row.get("run_agent_id"), + "occurrence_total": 0, + "duplicate_total": 0, + "linked_run_total": 0, + "source_ref_total": 0, + "missing_source_refs_total": 0, + "sentry_ref_total": 0, + "signoz_ref_total": 0, + "alert_ref_total": 0, + "run_state_counts": {}, + "first_received_at": received_at, + "latest_received_at": received_at, + "_run_ids": set(), + }, + ) + + group["occurrence_total"] += 1 + group["source_ref_total"] += source_ref_count + if source_ref_count <= 0: + group["missing_source_refs_total"] += 1 + if event.get("is_duplicate"): + group["duplicate_total"] += 1 + + group["sentry_ref_total"] += _ref_count(source_refs, "sentry_issue_ids") + group["signoz_ref_total"] += _ref_count(source_refs, "signoz_alerts") + group["alert_ref_total"] += _ref_count(source_refs, "alert_ids") + + if run_id: + group["_run_ids"].add(str(run_id)) + if group.get("latest_run_id") is None: + group["latest_run_id"] = run_id + group["latest_run_state"] = run_state + group["latest_agent_id"] = row.get("run_agent_id") + if run_state: + state_counts = group["run_state_counts"] + state_counts[str(run_state)] = int(state_counts.get(str(run_state), 0)) + 1 + + if received_at and ( + group.get("first_received_at") is None + or str(received_at) < str(group.get("first_received_at")) + ): + group["first_received_at"] = received_at + if received_at and ( + group.get("latest_received_at") is None + or str(received_at) > str(group.get("latest_received_at")) + ): + group["latest_received_at"] = received_at + + items = [] + linked_run_total = 0 + for group in groups.values(): + run_ids = group.pop("_run_ids") + group["linked_run_total"] = len(run_ids) + linked_run_total += len(run_ids) + items.append(group) + + items.sort(key=lambda item: str(item.get("latest_received_at") or ""), reverse=True) + items.sort(key=lambda item: int(item.get("occurrence_total") or 0), reverse=True) + latest_received_at = max( + (item.get("latest_received_at") for item in items if item.get("latest_received_at")), + default=None, + ) + + return { + "project_id": project_id, + "limit": limit, + "summary": { + "source_event_total": len(rows), + "recurrence_group_total": len(items), + "recurrent_group_total": sum( + 1 for item in items if int(item.get("occurrence_total") or 0) > 1 + ), + "duplicate_event_total": sum(int(item.get("duplicate_total") or 0) for item in items), + "linked_run_total": linked_run_total, + "unlinked_event_total": sum(1 for row in rows if not row.get("run_id")), + "latest_received_at": latest_received_at, + }, + "items": items, + } + + def build_dossier_coverage( rows: list[dict[str, Any]], *, @@ -290,3 +420,62 @@ async def fetch_channel_event_dossier_coverage( project_id=effective_project_id, limit=safe_limit, ) + + +async def fetch_channel_event_dossier_recurrence( + *, + project_id: str | None, + provider: str | None, + limit: int, +) -> dict[str, Any]: + """Fetch recurrence groups and linked run state for recent source events.""" + effective_project_id = project_id or "awoooi" + safe_limit = max(1, min(limit, _MAX_RECURRENCE_EVENTS)) + where_clauses = ["e.project_id = :project_id"] + params: dict[str, Any] = { + "project_id": effective_project_id, + "limit": safe_limit, + } + if provider: + where_clauses.append( + "COALESCE(NULLIF(e.source_envelope->>'provider', ''), " + "split_part(e.provider_event_id, ':', 1), e.channel_type) = :provider" + ) + params["provider"] = provider + + async with get_db_context(effective_project_id) as db: + result = await db.execute( + text(f""" + SELECT + e.event_id, + e.project_id, + e.channel_type, + e.provider_event_id, + e.content_hash, + e.content_preview, + e.content_redacted, + e.redaction_version, + e.source_envelope, + e.is_duplicate, + e.provider_ts, + e.received_at, + e.run_id, + r.state AS run_state, + r.agent_id AS run_agent_id + FROM awooop_conversation_event e + LEFT JOIN awooop_run_state r + ON r.project_id = e.project_id + AND r.run_id = e.run_id + WHERE {" AND ".join(where_clauses)} + ORDER BY e.received_at DESC + LIMIT :limit + """), + params, + ) + rows = [dict(row) for row in result.mappings().all()] + + return build_dossier_recurrence( + rows, + project_id=effective_project_id, + limit=safe_limit, + ) diff --git a/apps/api/tests/test_channel_event_dossier_service.py b/apps/api/tests/test_channel_event_dossier_service.py index 3a3545b1..c8506bf3 100644 --- a/apps/api/tests/test_channel_event_dossier_service.py +++ b/apps/api/tests/test_channel_event_dossier_service.py @@ -8,8 +8,10 @@ from src.services import channel_event_dossier_service from src.services.channel_event_dossier_service import ( build_dossier_event, build_dossier_coverage, + build_dossier_recurrence, fetch_channel_event_dossier, fetch_channel_event_dossier_coverage, + fetch_channel_event_dossier_recurrence, ) @@ -135,6 +137,112 @@ def test_build_dossier_coverage_summarizes_recent_sources() -> None: assert coverage["providers"][0]["provider"] == "sentry" +def test_build_dossier_recurrence_groups_events_and_run_state() -> None: + recurrence = build_dossier_recurrence( + [ + { + "event_id": "event-2", + "project_id": "awoooi", + "channel_type": "internal", + "provider_event_id": "alertmanager:received:2", + "content_hash": "b" * 64, + "content_preview": "Host disk pressure", + "content_redacted": "Host disk pressure", + "redaction_version": "audit_sink_v1", + "source_envelope": { + "provider": "alertmanager", + "source_refs": { + "alert_ids": ["alert-2"], + "fingerprints": ["fp-host-disk"], + }, + "log_correlation": { + "alertname": "HostDiskUsageHigh", + "severity": "warning", + "namespace": "node", + "target_resource": "host-110", + "fingerprint": "fp-host-disk", + }, + }, + "is_duplicate": True, + "provider_ts": None, + "received_at": "2026-05-13T13:47:00", + "run_id": UUID("11111111-1111-4111-8111-111111111111"), + "run_state": "waiting_approval", + "run_agent_id": "openclaw", + }, + { + "event_id": "event-1", + "project_id": "awoooi", + "channel_type": "internal", + "provider_event_id": "alertmanager:received:1", + "content_hash": "a" * 64, + "content_preview": "Host disk pressure", + "content_redacted": "Host disk pressure", + "redaction_version": "audit_sink_v1", + "source_envelope": { + "provider": "alertmanager", + "source_refs": { + "alert_ids": ["alert-1"], + "fingerprints": ["fp-host-disk"], + }, + "log_correlation": { + "alertname": "HostDiskUsageHigh", + "severity": "warning", + "namespace": "node", + "target_resource": "host-110", + "fingerprint": "fp-host-disk", + }, + }, + "is_duplicate": False, + "provider_ts": None, + "received_at": "2026-05-13T13:46:00", + "run_id": UUID("22222222-2222-4222-8222-222222222222"), + "run_state": "completed", + "run_agent_id": "openclaw", + }, + { + "event_id": "event-3", + "project_id": "awoooi", + "channel_type": "internal", + "provider_event_id": "sentry:received:issue-1", + "content_hash": "c" * 64, + "content_preview": "Sentry issue", + "content_redacted": "Sentry issue", + "redaction_version": "audit_sink_v1", + "source_envelope": { + "provider": "sentry", + "source_refs": {"sentry_issue_ids": ["issue-1"]}, + "log_correlation": {"alertname": "Sentry Issue"}, + }, + "is_duplicate": False, + "provider_ts": None, + "received_at": "2026-05-13T13:45:00", + "run_id": None, + "run_state": None, + "run_agent_id": None, + }, + ], + project_id="awoooi", + limit=100, + ) + + assert recurrence["summary"]["source_event_total"] == 3 + assert recurrence["summary"]["recurrence_group_total"] == 2 + assert recurrence["summary"]["recurrent_group_total"] == 1 + assert recurrence["summary"]["duplicate_event_total"] == 1 + assert recurrence["summary"]["linked_run_total"] == 2 + assert recurrence["summary"]["unlinked_event_total"] == 1 + + host_group = recurrence["items"][0] + assert host_group["recurrence_key"] == "fingerprint:fp-host-disk" + assert host_group["occurrence_total"] == 2 + assert host_group["duplicate_total"] == 1 + assert host_group["linked_run_total"] == 2 + assert host_group["latest_run_state"] == "waiting_approval" + assert host_group["run_state_counts"] == {"waiting_approval": 1, "completed": 1} + assert host_group["alert_ref_total"] == 2 + + @pytest.mark.asyncio async def test_fetch_channel_event_dossier_requires_source() -> None: with pytest.raises(HTTPException) as exc_info: @@ -242,3 +350,52 @@ async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(m "provider": "sentry", "limit": 200, } + + +@pytest.mark.asyncio +async def test_fetch_channel_event_dossier_recurrence_uses_joined_typed_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_recurrence( + project_id="awoooi", + provider="alertmanager", + limit=500, + ) + + assert result["project_id"] == "awoooi" + assert result["limit"] == 300 + assert "LEFT JOIN awooop_run_state r" in str(captured["sql"]) + assert "e.source_envelope->>'provider'" in str(captured["sql"]) + assert ":provider IS NULL" not in str(captured["sql"]) + assert captured["params"] == { + "project_id": "awoooi", + "provider": "alertmanager", + "limit": 300, + } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f2e651e5..3edcb605 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1901,6 +1901,44 @@ "duplicates": "Duplicates {count}" } }, + "eventRecurrence": { + "title": "Recurring Alert Links", + "subtitle": "Grouped by fingerprint / target resource with the latest Run stage", + "total": "{count} groups", + "empty": "No recent recurring alert links.", + "error": "Recurring alert links failed to load: {error}", + "metrics": { + "groups": "Link groups", + "recurrent": "Recurring groups", + "duplicates": "Duplicate events", + "linkedRuns": "Linked Runs" + }, + "details": { + "sourceEvents": "{count} source events", + "latest": "Latest {time}", + "unlinked": "{count} items not linked to a Run", + "limit": "Latest {count} item window" + }, + "states": { + "pending": "Pending", + "running": "Running", + "waiting_tool": "Waiting for tool", + "waiting_approval": "Waiting approval", + "completed": "Completed", + "failed": "Failed", + "cancelled": "Cancelled", + "timeout": "Timed out", + "unlinked": "Not linked" + }, + "item": { + "latest": "Latest {time}", + "duplicates": "Duplicates {count}", + "refs": "Refs {count}", + "linkedRuns": "Runs {count}", + "openRun": "Open Run", + "noRun": "No Run yet" + } + }, "callbackReply": { "count": "{total} items; fallback {fallback}; failed {failed}", "emptyShort": "No detail / history callback yet", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 2756cadc..be1d55ab 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1902,6 +1902,44 @@ "duplicates": "重複 {count}" } }, + "eventRecurrence": { + "title": "重複告警關聯", + "subtitle": "依 fingerprint / 目標資源聚合,顯示是否重複與最新 Run 階段", + "total": "{count} 組", + "empty": "目前沒有近期重複告警關聯。", + "error": "重複告警關聯載入失敗:{error}", + "metrics": { + "groups": "關聯群組", + "recurrent": "重複群組", + "duplicates": "重複事件", + "linkedRuns": "已連 Run" + }, + "details": { + "sourceEvents": "{count} 筆來源事件", + "latest": "最新 {time}", + "unlinked": "{count} 筆尚未連 Run", + "limit": "最近 {count} 筆視窗" + }, + "states": { + "pending": "待執行", + "running": "執行中", + "waiting_tool": "等待工具", + "waiting_approval": "等待審批", + "completed": "已完成", + "failed": "失敗", + "cancelled": "已取消", + "timeout": "已超時", + "unlinked": "尚未連 Run" + }, + "item": { + "latest": "最新 {time}", + "duplicates": "重複 {count}", + "refs": "Refs {count}", + "linkedRuns": "Run {count}", + "openRun": "開啟 Run", + "noRun": "尚無 Run" + } + }, "callbackReply": { "count": "{total} 筆;fallback {fallback};失敗 {failed}", "emptyShort": "尚無詳情 / 歷史 callback", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 7e8b6d1c..e603805f 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -176,6 +176,50 @@ interface DossierCoverageResponse { providers: DossierCoverageProvider[]; } +interface EventRecurrenceSummary { + source_event_total: number; + recurrence_group_total: number; + recurrent_group_total: number; + duplicate_event_total: number; + linked_run_total: number; + unlinked_event_total: number; + latest_received_at?: string | null; +} + +interface EventRecurrenceItem { + recurrence_key: string; + provider?: string | null; + alertname?: string | null; + severity?: string | null; + namespace?: string | null; + target_resource?: string | null; + fingerprint?: string | null; + latest_event_id?: string | null; + latest_provider_event_id?: string | null; + latest_content_preview?: string | null; + latest_run_id?: string | null; + latest_run_state?: RunState | string | null; + latest_agent_id?: string | null; + occurrence_total: number; + duplicate_total: number; + linked_run_total: number; + source_ref_total: number; + missing_source_refs_total: number; + sentry_ref_total: number; + signoz_ref_total: number; + alert_ref_total: number; + run_state_counts: Record; + first_received_at?: string | null; + latest_received_at?: string | null; +} + +interface EventRecurrenceResponse { + project_id: string; + limit: number; + summary: EventRecurrenceSummary; + items: EventRecurrenceItem[]; +} + interface CallbackReplyEvent { message_id: string; run_id: string; @@ -884,6 +928,179 @@ function SourceDossierCoveragePanel({ ); } +function recurrenceStateLabelKey(state?: string | null) { + if ( + state === "pending" || + state === "running" || + state === "waiting_tool" || + state === "waiting_approval" || + state === "completed" || + state === "failed" || + state === "cancelled" || + state === "timeout" + ) { + return `states.${state}`; + } + return "states.unlinked"; +} + +function EventRecurrencePanel({ + recurrence, + error, +}: { + recurrence: EventRecurrenceResponse | null; + error: string | null; +}) { + const t = useTranslations("awooop.eventRecurrence"); + const summary = recurrence?.summary; + const items = recurrence?.items ?? []; + 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.groups"), + value: summary?.recurrence_group_total ?? 0, + detail: t("details.sourceEvents", { count: summary?.source_event_total ?? 0 }), + className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }, + { + label: t("metrics.recurrent"), + value: summary?.recurrent_group_total ?? 0, + detail: t("details.latest", { time: latestAt }), + className: (summary?.recurrent_group_total ?? 0) > 0 + ? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]" + : "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + }, + { + label: t("metrics.duplicates"), + value: summary?.duplicate_event_total ?? 0, + detail: t("details.unlinked", { count: summary?.unlinked_event_total ?? 0 }), + className: "border-[#d8d3c7] bg-white text-[#5f5b52]", + }, + { + label: t("metrics.linkedRuns"), + value: summary?.linked_run_total ?? 0, + detail: t("details.limit", { count: recurrence?.limit ?? 0 }), + className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + }, + ]; + + return ( +
+
+
+
+ + {t("total", { count: items.length })} + +
+ + {error ? ( +
+ {t("error", { error })} +
+ ) : ( + <> +
+ {metrics.map((item) => ( +
+
+
+

{item.label}

+
+ {item.value} +
+
+ + +
+

{item.detail}

+
+ ))} +
+ + {items.length === 0 ? ( +
+ {t("empty")} +
+ ) : ( +
+ {items.slice(0, 6).map((item) => { + const latest = item.latest_received_at + ? new Date(item.latest_received_at).toLocaleTimeString("zh-TW", { + hour: "2-digit", + minute: "2-digit", + }) + : "--"; + const stateKey = recurrenceStateLabelKey(item.latest_run_state); + const stateLabel = t(stateKey as never); + const runHref = item.latest_run_id + ? `/awooop/runs/${item.latest_run_id}?project_id=${encodeURIComponent(recurrence?.project_id ?? "awoooi")}` + : null; + + return ( +
+
+
+

+ {item.alertname || item.provider || item.latest_provider_event_id || item.recurrence_key} +

+

+ {item.namespace || "--"} · {item.target_resource || "--"} +

+
+ + {item.occurrence_total} + +
+
+

{t("item.latest", { time: latest })}

+

{t("item.duplicates", { count: item.duplicate_total })}

+

{t("item.refs", { count: item.source_ref_total })}

+

{t("item.linkedRuns", { count: item.linked_run_total })}

+
+
+ + {stateLabel} + + {runHref ? ( + +
+
+ ); + })} +
+ )} + + )} +
+ ); +} + function GroupedAlertEventsPanel({ events }: { events: PlatformEvent[] }) { return (
@@ -1053,6 +1270,8 @@ export default function RunsPage() { const [groupedEvents, setGroupedEvents] = useState([]); const [dossierCoverage, setDossierCoverage] = useState(null); const [dossierCoverageError, setDossierCoverageError] = useState(null); + const [eventRecurrence, setEventRecurrence] = useState(null); + const [eventRecurrenceError, setEventRecurrenceError] = useState(null); const [callbackEvents, setCallbackEvents] = useState([]); const [callbackEventsTotal, setCallbackEventsTotal] = useState(0); const [callbackEventsError, setCallbackEventsError] = useState(null); @@ -1150,6 +1369,21 @@ export default function RunsPage() { setDossierCoverageError(`HTTP ${dossierCoverageRes.status}`); } + const recurrenceParams = new URLSearchParams(); + recurrenceParams.set("limit", "100"); + if (projectFilter) recurrenceParams.set("project_id", projectFilter); + const recurrenceRes = await fetch( + `${API_BASE}/api/v1/platform/events/dossier/recurrence?${recurrenceParams.toString()}` + ); + if (recurrenceRes.ok) { + const recurrenceData: EventRecurrenceResponse = await recurrenceRes.json(); + setEventRecurrence(recurrenceData); + setEventRecurrenceError(null); + } else { + setEventRecurrence(null); + setEventRecurrenceError(`HTTP ${recurrenceRes.status}`); + } + const callbackParams = new URLSearchParams(); callbackParams.set("per_page", "6"); if (projectFilter) callbackParams.set("project_id", projectFilter); @@ -1367,6 +1601,11 @@ export default function RunsPage() { error={dossierCoverageError} /> + +