feat(awooop): show inbound event dossiers
This commit is contained in:
@@ -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,
|
||||
|
||||
133
apps/api/src/services/channel_event_dossier_service.py
Normal file
133
apps/api/src/services/channel_event_dossier_service.py
Normal file
@@ -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),
|
||||
},
|
||||
}
|
||||
65
apps/api/tests/test_channel_event_dossier_service.py
Normal file
65
apps/api/tests/test_channel_event_dossier_service.py
Normal file
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileSearch,
|
||||
Link2,
|
||||
MessageSquareText,
|
||||
RefreshCw,
|
||||
Route,
|
||||
@@ -60,6 +62,43 @@ interface TimelineItem {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
source_ref_count: number;
|
||||
log_correlation: Record<string, unknown>;
|
||||
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<string, string> = {
|
||||
waiting_approval: "statuses.waitingApproval",
|
||||
};
|
||||
|
||||
const SOURCE_REF_LABEL_KEYS: Record<string, string> = {
|
||||
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<string, unknown>, 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 (
|
||||
<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">
|
||||
<FileSearch className="h-4 w-4 text-brand-accent" aria-hidden="true" />
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
|
||||
</div>
|
||||
<span className={cn("border px-2 py-0.5 text-xs font-semibold", events.length > 0 ? statusClass("received") : statusClass("pending"))}>
|
||||
{events.length > 0 ? t("status.visible") : t("status.empty")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
|
||||
{metrics.map((item) => (
|
||||
<div key={item.label} className="bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
|
||||
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<div className="px-4 py-8 text-sm text-[#77736a]">{t("empty")}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[#eee9dd]">
|
||||
{events.map((event) => {
|
||||
const refs = firstSourceRefs(event.source_refs);
|
||||
const digest = shortDigest(event.content_sha256);
|
||||
return (
|
||||
<article key={event.event_id} className="grid gap-4 px-4 py-4 xl:grid-cols-[240px_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 font-mono text-xs font-semibold text-[#5f5b52]">
|
||||
{event.provider ?? event.channel_type}
|
||||
</span>
|
||||
<span className={cn("border px-2 py-0.5 text-xs font-semibold", statusClass(event.is_duplicate ? "pending" : "received"))}>
|
||||
{event.is_duplicate ? t("duplicate") : t("firstSeen")}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="mt-2 text-sm font-semibold text-[#141413]">
|
||||
{event.alertname || event.provider_event_id}
|
||||
</h4>
|
||||
<p className="mt-1 font-mono text-xs text-[#77736a]">
|
||||
{formatTime(event.received_at, locale, emptyLabel)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<DetailLine label={t("fields.stage")} value={event.stage} emptyLabel={emptyLabel} />
|
||||
<DetailLine label={t("fields.severity")} value={event.severity} emptyLabel={emptyLabel} />
|
||||
<DetailLine label={t("fields.namespace")} value={event.namespace} emptyLabel={emptyLabel} />
|
||||
<DetailLine label={t("fields.target")} value={event.target_resource} emptyLabel={emptyLabel} />
|
||||
<DetailLine label={t("fields.hash")} value={digest} emptyLabel={emptyLabel} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="rounded-none border border-[#eee9dd] bg-[#faf9f3] p-3">
|
||||
<div className="mb-2 text-xs font-semibold text-[#77736a]">{t("content")}</div>
|
||||
<p className="max-h-28 overflow-hidden whitespace-pre-line break-words text-sm leading-6 text-[#5f5b52]">
|
||||
{event.content_redacted || event.content_preview || emptyLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold text-[#77736a]">{t("sourceRefs")}</div>
|
||||
{refs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{refs.map((ref) => {
|
||||
const labelKey = SOURCE_REF_LABEL_KEYS[ref.key];
|
||||
const label = labelKey ? t(labelKey as never) : ref.key;
|
||||
return (
|
||||
<span key={`${ref.key}:${ref.value}`} className="inline-flex max-w-full items-center gap-1 border border-[#d8d3c7] bg-white px-2 py-1 text-xs text-[#5f5b52]">
|
||||
<span className="shrink-0 font-semibold">{label}</span>
|
||||
<span className="truncate font-mono">{ref.value}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[#77736a]">{emptyLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
{event.source_url && (
|
||||
<a
|
||||
href={event.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex max-w-full items-center gap-2 border border-[#d8d3c7] bg-white px-3 py-2 text-sm text-[#5f5b52] hover:border-[#d97757]"
|
||||
>
|
||||
<Link2 className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{event.source_url}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailLine({
|
||||
label,
|
||||
value,
|
||||
emptyLabel,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string | number | null;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[84px_1fr] gap-2">
|
||||
<span className="font-semibold text-[#77736a]">{label}</span>
|
||||
<span className="truncate font-mono text-[#141413]">{value ?? emptyLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
@@ -427,6 +628,7 @@ export default function RunDetailPage({
|
||||
const projectId = searchParams.get("project_id") ?? "";
|
||||
|
||||
const [detail, setDetail] = useState<RunDetailResponse | null>(null);
|
||||
const [dossier, setDossier] = useState<ChannelEventDossierResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(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}
|
||||
/>
|
||||
|
||||
<EventDossierPanel dossier={dossier} locale={locale} emptyLabel={t("empty")} />
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[360px_1fr]">
|
||||
<aside className="border border-[#e0ddd4] bg-white">
|
||||
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
|
||||
Reference in New Issue
Block a user