feat(awooop): surface AI alert card delivery readback

This commit is contained in:
ogt
2026-06-25 09:48:19 +08:00
parent b4d9cbb69d
commit d4f3953847
3 changed files with 285 additions and 3 deletions

View File

@@ -538,6 +538,59 @@ interface CallbackRepliesResponse {
cache?: OperatorSummaryCacheInfo | null;
}
interface AiAlertCardDeliverySummary {
schema_version: string;
project_id: string;
event_type?: string | null;
lane?: string | null;
status: string;
total: number;
sent_total: number;
failed_total: number;
pending_total: number;
shadow_total: number;
delivery_receipt_required_total: number;
runtime_write_gate_open_count: number;
runtime_write_allowed: boolean;
latest_sent_at?: string | null;
latest_queued_at?: string | null;
production_write_count: number;
}
interface AiAlertCardDeliveryItem {
message_id: string;
run_id: string;
project_id: string;
event_at?: string | null;
channel_type: string;
message_type: string;
send_status: string;
send_error?: string | null;
provider_message_id?: string | null;
triggered_by_state?: string | null;
event_type: string;
lane: string;
target: string;
gates: string[];
runtime_write_gate_count: number;
runtime_write_allowed: boolean;
candidate_only: boolean;
delivery_receipt_readback_required: boolean;
source_refs: Record<string, unknown>;
run_state?: string | null;
agent_id?: string | null;
run_created_at?: string | null;
run_detail_href?: string | null;
}
interface AiAlertCardsResponse {
items?: AiAlertCardDeliveryItem[];
total?: number;
page?: number;
per_page?: number;
summary?: AiAlertCardDeliverySummary | null;
}
interface AiRoutePolicyItem {
priority: number;
provider_name: string;
@@ -647,6 +700,8 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const PER_PAGE = 50;
const AUTO_REFRESH_INTERVAL = 30_000; // 30 秒
const INCIDENT_ID_FILTER_RE = /^INC-\d{8}-[A-Z0-9]{4,}$/;
const WAZUH_ALERT_CARD_EVENT_TYPE = "wazuh_dashboard_api_readback_degraded";
const WAZUH_ALERT_CARD_LANE = "siem_observability_readback_degraded";
const STATE_CONFIG: Record<
RunState,
@@ -3517,6 +3572,162 @@ function CallbackReplyEvidencePanel({
);
}
function AiAlertCardDeliveryPanel({
items,
summary,
loading,
error,
}: {
items: AiAlertCardDeliveryItem[];
summary?: AiAlertCardDeliverySummary | null;
loading: boolean;
error: string | null;
}) {
const latestValue = summary?.latest_sent_at ?? summary?.latest_queued_at;
const latest = latestValue
? new Date(latestValue).toLocaleTimeString("zh-TW", {
hour: "2-digit",
minute: "2-digit",
})
: "--";
const total = summary?.total ?? 0;
const sent = summary?.sent_total ?? 0;
const failed = summary?.failed_total ?? 0;
const pending = summary?.pending_total ?? 0;
const runtimeGateOpen = summary?.runtime_write_gate_open_count ?? 0;
const statusLabel = total === 0
? "尚無送達讀回"
: failed > 0
? "有送達失敗"
: pending > 0
? "等待送達"
: "已有送達讀回";
const statusClass = failed > 0
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
: total === 0 || pending > 0
? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
const sendStatusLabel = (status: string) => ({
sent: "已送達",
failed: "失敗",
pending: "等待",
shadow: "影子",
}[status] ?? "未知狀態");
const laneLabel = (lane: string) => ({
siem_observability_readback_degraded: "SIEM 觀測讀回退化",
}[lane] ?? "未分類分流");
const targetLabel = (target: string) => ({
wazuh_dashboard_api: "Wazuh 儀表板/API 讀回",
}[target] ?? "未分類目標");
return (
<section
id="ai-alert-card-delivery-readback"
data-testid="awooop-ai-alert-card-delivery-readback"
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">
<ShieldCheck className="h-4 w-4 text-[#17602a]" aria-hidden="true" />
<div>
<h3 className="text-sm font-semibold text-[#141413]">AI </h3>
<p className="text-xs text-[#77736a]">
Wazuh /API 退 ·
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className={cn("border px-2 py-0.5 text-xs font-semibold", statusClass)}>
{statusLabel}
</span>
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5 text-xs font-semibold text-[#5f5b52]">
{runtimeGateOpen}
</span>
</div>
</div>
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-4">
{[
{ label: "總數", value: total, detail: `最新 ${latest}` },
{ label: "已送達", value: sent, detail: "Telegram 鏡像已送達" },
{ label: "失敗", value: failed, detail: "需查送達錯誤" },
{ label: "等待", value: pending, detail: `影子 ${summary?.shadow_total ?? 0}` },
].map((metric) => (
<div key={metric.label} className="bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{metric.label}</p>
<p className="mt-1 font-mono text-2xl font-semibold text-[#141413]">{metric.value}</p>
<p className="mt-1 text-xs text-[#5f5b52]">{metric.detail}</p>
</div>
))}
</div>
{error ? (
<div className="px-4 py-4 text-sm text-[#9f2f25]">{error}</div>
) : items.length > 0 ? (
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2 xl:grid-cols-3">
{items.slice(0, 6).map((item) => {
const eventTime = item.event_at
? new Date(item.event_at).toLocaleTimeString("zh-TW", {
hour: "2-digit",
minute: "2-digit",
})
: "--";
const sourceRefCount = Object.values(item.source_refs ?? {}).reduce<number>((count, value) => {
return count + (Array.isArray(value) ? value.length : 0);
}, 0);
const runHref = item.run_detail_href
?? `/awooop/runs/${item.run_id}?project_id=${encodeURIComponent(item.project_id)}`;
return (
<article key={item.message_id} 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.run_id.slice(0, 8)}
</p>
<p className="mt-1 text-xs text-[#77736a]">
{publicProjectText(item.project_id)} · {eventTime}
</p>
</div>
<span className={cn(
"shrink-0 border px-2 py-0.5 text-xs font-semibold",
item.send_status === "sent"
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
: item.send_status === "failed"
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
)}>
{sendStatusLabel(item.send_status)}
</span>
</div>
<div className="mt-3 space-y-1 text-xs leading-5 text-[#5f5b52]">
<p><span className="font-semibold">{laneLabel(item.lane)}</span></p>
<p><span className="font-semibold">{targetLabel(item.target)}</span></p>
<p><span className="font-mono">{sourceRefCount}</span></p>
<p><span className="font-mono">{item.runtime_write_gate_count}</span></p>
</div>
<Link
href={runHref as never}
className="mt-3 inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-[#faf9f3] 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" />
</Link>
</article>
);
})}
</div>
) : loading ? (
<div className="px-4 py-4 text-sm text-[#5f5b52]">...</div>
) : (
<div className="px-4 py-4 text-sm text-[#5f5b52]">
Wazuh /API 退
</div>
)}
</section>
);
}
function aiRouteHealthLabelKey(status?: string | null) {
if (
status === "healthy" ||
@@ -3981,6 +4192,10 @@ export default function RunsPage() {
const [callbackEventsLoading, setCallbackEventsLoading] = useState(true);
const [callbackCacheInfo, setCallbackCacheInfo] = useState<OperatorSummaryCacheInfo | null>(null);
const [callbackEventsError, setCallbackEventsError] = useState<string | null>(null);
const [aiAlertCardItems, setAiAlertCardItems] = useState<AiAlertCardDeliveryItem[]>([]);
const [aiAlertCardSummary, setAiAlertCardSummary] = useState<AiAlertCardDeliverySummary | null>(null);
const [aiAlertCardLoading, setAiAlertCardLoading] = useState(true);
const [aiAlertCardError, setAiAlertCardError] = useState<string | null>(null);
const [aiRouteStatus, setAiRouteStatus] = useState<AiRouteStatusResponse | null>(null);
const [aiRouteStatusError, setAiRouteStatusError] = useState<string | null>(null);
const [automationQualitySummary, setAutomationQualitySummary] =
@@ -4159,6 +4374,37 @@ export default function RunsPage() {
setCallbackEventsLoading(false);
}
const aiAlertParams = new URLSearchParams();
aiAlertParams.set("project_id", projectFilter || "awoooi");
aiAlertParams.set("event_type", WAZUH_ALERT_CARD_EVENT_TYPE);
aiAlertParams.set("lane", WAZUH_ALERT_CARD_LANE);
aiAlertParams.set("per_page", "6");
if (options?.refresh) {
aiAlertParams.set("refresh", "true");
}
setAiAlertCardLoading(true);
try {
const aiAlertRes = await fetch(
`${API_BASE}/api/v1/platform/runs/ai-alert-cards?${aiAlertParams.toString()}`
);
if (aiAlertRes.ok) {
const aiAlertData: AiAlertCardsResponse = await aiAlertRes.json();
setAiAlertCardItems(Array.isArray(aiAlertData.items) ? aiAlertData.items : []);
setAiAlertCardSummary(aiAlertData.summary ?? null);
setAiAlertCardError(null);
} else {
setAiAlertCardItems([]);
setAiAlertCardSummary(null);
setAiAlertCardError(`端點尚未部署或暫時不可用HTTP ${aiAlertRes.status}`);
}
} catch (aiAlertError) {
setAiAlertCardItems([]);
setAiAlertCardSummary(null);
setAiAlertCardError("無法讀回 AI 事件卡送達狀態");
} finally {
setAiAlertCardLoading(false);
}
try {
const qualityParams = new URLSearchParams();
qualityParams.set("project_id", projectFilter || "awoooi");
@@ -4189,6 +4435,7 @@ export default function RunsPage() {
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
setCallbackEventsLoading(false);
setAiAlertCardLoading(false);
} finally {
setLoading(false);
}
@@ -4423,6 +4670,13 @@ export default function RunsPage() {
<GroupedAlertEventsPanel events={groupedEvents} />
<AiAlertCardDeliveryPanel
items={aiAlertCardItems}
summary={aiAlertCardSummary}
loading={aiAlertCardLoading}
error={aiAlertCardError}
/>
<CallbackReplyEvidencePanel
events={callbackEvents}
total={callbackEventsTotal}