feat(awooop): surface AI alert card delivery readback
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user