feat(awooop): show recurring alert links
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m6s
CD Pipeline / build-and-deploy (push) Successful in 3m55s
CD Pipeline / post-deploy-checks (push) Successful in 1m57s

This commit is contained in:
Your Name
2026-05-18 19:23:37 +08:00
parent d709e25d69
commit 94f8c68b77
6 changed files with 727 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, number>;
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 (
<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">
<Activity className="h-4 w-4 text-[#8a5a08]" 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-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 text-xs font-semibold text-[#8a5a08]">
{t("total", { count: items.length })}
</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-2 xl:grid-cols-4">
{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
)}
>
<Activity 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>
{items.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-3">
{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 (
<article key={item.recurrence_key} 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]">
{item.alertname || item.provider || item.latest_provider_event_id || item.recurrence_key}
</p>
<p className="mt-1 truncate text-xs text-[#77736a]">
{item.namespace || "--"} · {item.target_resource || "--"}
</p>
</div>
<span className="shrink-0 border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 font-mono text-xs font-semibold text-[#8a5a08]">
{item.occurrence_total}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs leading-5 text-[#5f5b52]">
<p>{t("item.latest", { time: latest })}</p>
<p>{t("item.duplicates", { count: item.duplicate_total })}</p>
<p>{t("item.refs", { count: item.source_ref_total })}</p>
<p>{t("item.linkedRuns", { count: item.linked_run_total })}</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 text-xs font-semibold text-[#5f5b52]">
{stateLabel}
</span>
{runHref ? (
<Link
href={runHref as never}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{t("item.openRun")}
</Link>
) : (
<span className="text-xs text-[#77736a]">{t("item.noRun")}</span>
)}
</div>
</article>
);
})}
</div>
)}
</>
)}
</section>
);
}
function GroupedAlertEventsPanel({ events }: { events: PlatformEvent[] }) {
return (
<section className="border border-[#e0ddd4] bg-white">
@@ -1053,6 +1270,8 @@ export default function RunsPage() {
const [groupedEvents, setGroupedEvents] = useState<PlatformEvent[]>([]);
const [dossierCoverage, setDossierCoverage] = useState<DossierCoverageResponse | null>(null);
const [dossierCoverageError, setDossierCoverageError] = useState<string | null>(null);
const [eventRecurrence, setEventRecurrence] = useState<EventRecurrenceResponse | null>(null);
const [eventRecurrenceError, setEventRecurrenceError] = useState<string | null>(null);
const [callbackEvents, setCallbackEvents] = useState<CallbackReplyEvent[]>([]);
const [callbackEventsTotal, setCallbackEventsTotal] = useState(0);
const [callbackEventsError, setCallbackEventsError] = useState<string | null>(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}
/>
<EventRecurrencePanel
recurrence={eventRecurrence}
error={eventRecurrenceError}
/>
<GroupedAlertEventsPanel events={groupedEvents} />
<CallbackReplyEvidencePanel