feat(governance): AI 治理事件處理鏈四軌交付(C/D/B/A)
Some checks failed
Code Review / ai-code-review (push) Successful in 48s
run-migration / migrate (push) Failing after 45s
CD Pipeline / tests (push) Successful in 3m46s
Type Sync Check / check-type-sync (push) Successful in 2m8s
CD Pipeline / build-and-deploy (push) Failing after 31m14s
CD Pipeline / post-deploy-checks (push) Has been skipped

【十二人專家團隊全景掃描 + 並行四軌實施】

統帥質疑「有讓 12-agent 一起協作嗎」後,依照團隊規則完成全鏈路交付:
onboarder + critic + db-expert + debugger + frontend-designer 並行掃描,
找到 6 大 Gap,再由 fullstack-engineer × 4、refactor-specialist 協作落地。

【Track C — trust_drift 雙寫整併】

兩條獨立寫 event_type=trust_drift 路徑互不呼叫,下游 consumer 拿到雙份資料
無法判定 source-of-truth。整併保留 governance_agent.check_trust_drift(功能
更全:auto-deprecate + Telegram + PG),TrustDriftDetector 降為純統計 lib,
W-6 watchdog 改呼叫 governance_agent。新增 TestSinglePgWritePerDriftScenario
驗證同一 drift 場景只觸發一次 PG 寫入。

  變更:
    - apps/api/src/services/trust_drift_detector.py(lib only,不再寫 PG)
    - apps/api/tests/test_trust_drift_watchdog.py(W-6 改 mock governance_agent)

【Track D — governance_remediation_dispatch 派遣表】

ai_governance_events 是不可變 Event Sourcing,不能塞執行狀態。新建派遣表
作為投影層:1 event → 0..N dispatches,狀態可變、可重試、可審計。

  - PgEnum 5 種 event_type + 7 階段狀態機(pending → dispatched → executing →
    succeeded/failed/cancelled/skipped)
  - 失敗重試 INSERT 新 row(不改舊 row 的 status,保留審計痕跡)
  - Partial unique index ux_grd_one_active_per_event 強制「同事件唯一活躍」
  - 4 個複合 index 支援 worker poll、去重查詢、觀測面板
  - FK 對應 ai_governance_events / playbooks / incidents / approval_records
    全部 SET NULL(avoid cascade lock,但 governance_event 用 RESTRICT)

  變更:
    - apps/api/src/db/models.py(GovernanceRemediationDispatch ORM class)
    - apps/api/migrations/governance_remediation_dispatch_2026-05-03.sql
    - apps/api/src/repositories/governance_remediation_dispatch_repo.py
      (6 個 async 函式 + 3 個自訂例外:DispatchAlreadyActive /
       InvalidStatusTransition / DispatchNotFound)
    - apps/api/src/models/governance_dispatch.py(DecisionContextV1 等 4 schema)
    - apps/api/tests/test_governance_remediation_dispatch.py(29 tests)

【Track B — /governance 頁面】

後端 PR1 三個 endpoint + 前端 PR2-5 完整三 Tab。

PR1 後端:
  - GET /api/v1/ai/governance/events(events_tab,含 event_type/severity/
    狀態/時間範圍篩選 + 分頁)
  - GET /api/v1/ai/governance/queue(queue_tab,含 graceful fallback:
    dispatch 表不存在時回 table_pending=True 不拋 500)
  - GET /api/v1/ai/governance/summary(slo_tab 30d 違反時序圖)
  - severity 映射規則寫死(critic 建議未來移 settings)

PR2-5 前端:
  - /governance 路由 + AppLayout + Compliance Badge 橫幅 + PageTabs
  - SLO Tab:3 KPI 卡片(Syne 28px + StatusOrb + 7d sparkline)+
    30d 違反 stacked BarChart
  - Events Tab:篩選列 + 表格 + inline 展開行(JSON / 修復建議 / 派遣記錄)
  - Queue Tab:HITL 待辦卡片 + 信任度進度條 + 批准/拒絕按鈕(本 PR console.log)
  - Sidebar 加入「AI 治理」入口(ShieldCheck icon)
  - i18n 雙語完整(governance namespace + nav.governance)
  - 7 個新元件:slo-kpi-card / slo-violation-chart / events-table /
    events-filter-bar / event-detail-drawer / queue-item-card / queue-history-tabs

  變更:
    - apps/api/src/api/v1/ai_governance.py(router)
    - apps/api/src/services/governance_query_service.py
    - apps/api/src/models/governance.py(Pydantic V2 schemas)
    - apps/api/tests/test_ai_governance_endpoints.py(21 tests)
    - apps/web/src/app/[locale]/governance/(page + 3 tabs)
    - apps/web/src/components/governance/(7 元件)
    - apps/web/messages/{zh-TW,en}.json(governance namespace)
    - apps/web/src/components/layout/sidebar.tsx(+1 行)
    - apps/api/src/main.py(router include)

【Track A — GovernanceDispatcher 決策融合】

把治理事件接到 remediation 執行器,走北極星方向決策融合(LLM × Playbook trust
× MCP),符合「禁寫死規則」鐵律。

  - 設計鐵律:DecisionFusionAdapter 是新增 wrapper,**不修改任何 Tier 3 檔**
    (decision_manager / learning_service / trust_engine),只 consume 既有 API
  - 三維融合公式:confidence = 0.4×llm + 0.3×playbook_trust + 0.3×mcp_consistency
    (權重加 TODO 標明未來由 AI 自學調整)
  - 三分支決策路徑:
    confidence ≥ 0.85 → auto_dispatch(status=dispatched)
    0.65 ≤ confidence < 0.85 → pending_approval(HITL)
    confidence < 0.65 → skip + log
  - decision_context JSONB 完整記錄三維輸入快照(給未來 fine-tune 用)
  - poll 30s 掃 unresolved 事件,仿 governance loop 模式
  - 重複事件擋去重(呼叫 get_active_for_event)

  變更:
    - apps/api/src/services/governance_dispatcher.py
    - apps/api/src/services/decision_fusion_adapter.py
    - apps/api/tests/test_governance_dispatcher.py(14 tests)
    - apps/api/src/main.py(lifespan task 接 run_governance_dispatcher_loop)

【驗證】

1836 個 unit test 全過(29 skipped 為既有 PG integration env 問題)

【調度教訓 — 已記入 memory】

- vuln-verifier 應在 fullstack-engineer **之前**跑(避免並行讀到已修代碼誤判)
- critic 雙輪審查不可省(第二輪抓到 NaN sentinel + Prom rule 連鎖)
- 北極星「禁寫死規則」搭配 decision-fusion 確實實施

【未動 Tier 3 — 已驗證】

git diff 確認本 commit 完全沒改 decision_manager.py / learning_service.py /
trust_engine.py,只新增 wrapper service consume 既有 API。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-03 12:42:40 +08:00
parent 577250a678
commit e45b055e0e
29 changed files with 6510 additions and 92 deletions

View File

@@ -0,0 +1,49 @@
'use client'
/**
* GovernancePage — AI 治理中樞 (/governance)
* ============================================
* PR 2: 頁面骨架 + Sidebar 入口 + 三 Tab 空殼
* PR 3-5 將填入 SLO / 治理事件 / AI 待辦 真實內容
*
* 設計方向: Nothing.tech × Anthropic Warmth
* - ComplianceBadge 橫幅作為頁面頂部的治理狀態信號
* - 0.5px 邊框 / DM Mono 字體 / #d97757 橘紅 accent
*
* @created 2026-05-02 Claude Sonnet 4.6 — governance PR 2
*/
import { useTranslations } from 'next-intl'
import { ShieldCheck } from 'lucide-react'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { GlassCard } from '@/components/ui/glass-card'
import { SloTab } from './tabs/slo-tab'
import { EventsTab } from './tabs/events-tab'
import { QueueTab } from './tabs/queue-tab'
export default function GovernancePage({ params }: { params: { locale: string } }) {
const t = useTranslations('governance')
const tabs: TabConfig[] = [
{ id: 'slo', label: t('tabs.slo'), content: <SloTab /> },
{ id: 'events', label: t('tabs.events'), content: <EventsTab /> },
{ id: 'queue', label: t('tabs.queue'), content: <QueueTab /> },
]
return (
<AppLayout locale={params.locale}>
{/* ComplianceBadge 橫幅 — PR 3 接 /governance/compliance-score API */}
<GlassCard variant="subtle" padding="sm" className="mb-3">
<div className="flex items-center gap-2">
<ShieldCheck size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontWeight: 700, fontSize: 15, color: '#141413' }}>
{t('complianceBadge.label')}
</span>
</div>
</GlassCard>
<PageTabs tabs={tabs} defaultTab="slo" syncWithUrl={true} />
</AppLayout>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
/**
* EventsTab — 治理事件日誌 Tab
* ==============================
* 消費GET /api/v1/ai/governance/events含 query params
*
* 內容:
* 1. EventsFilterBar — 篩選列
* 2. EventsTable — 表格 + 展開行 + 分頁
*
* @updated 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5 填入真實內容
*/
import { useEffect, useState, useCallback } from 'react'
import { EventsFilterBar, type EventsFilter } from '@/components/governance/events-filter-bar'
import { EventsTable, type GovernanceEvent } from '@/components/governance/events-table'
// =============================================================================
// Config
// =============================================================================
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
const PAGE_SIZE = 20
// =============================================================================
// API response type
// =============================================================================
interface EventsApiResponse {
items?: GovernanceEvent[]
total?: number
page?: number
page_size?: number
event_types?: string[]
}
// =============================================================================
// Helpers
// =============================================================================
function buildQueryString(filter: EventsFilter, page: number): string {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('page_size', String(PAGE_SIZE))
if (filter.eventTypes.length > 0) {
filter.eventTypes.forEach(t => params.append('event_type', t))
}
if (filter.status !== 'all') params.set('status', filter.status)
if (filter.severity !== 'all') params.set('severity', filter.severity)
if (filter.dateFrom) params.set('date_from', filter.dateFrom)
if (filter.dateTo) params.set('date_to', filter.dateTo)
return params.toString()
}
// =============================================================================
// Component
// =============================================================================
const DEFAULT_FILTER: EventsFilter = {
eventTypes: [],
status: 'all',
severity: 'all',
dateFrom: '',
dateTo: '',
}
export function EventsTab() {
const [filter, setFilter] = useState<EventsFilter>(DEFAULT_FILTER)
const [page, setPage] = useState(1)
const [events, setEvents] = useState<GovernanceEvent[]>([])
const [total, setTotal] = useState(0)
const [availableEventTypes, setAvailableEventTypes] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const fetchEvents = useCallback(() => {
setLoading(true)
const qs = buildQueryString(filter, page)
fetch(`${API_BASE}/api/v1/ai/governance/events?${qs}`)
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then((d: EventsApiResponse) => {
setEvents(d.items ?? [])
setTotal(d.total ?? 0)
if (d.event_types && d.event_types.length > 0) {
setAvailableEventTypes(d.event_types)
}
setError(false)
})
.catch(() => setError(true))
.finally(() => setLoading(false))
}, [filter, page])
// Re-fetch when filter or page changes
useEffect(() => {
fetchEvents()
}, [fetchEvents])
// Reset page when filter changes
const handleFilterChange = (newFilter: EventsFilter) => {
setFilter(newFilter)
setPage(1)
}
return (
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Filter bar */}
<EventsFilterBar
filter={filter}
onChange={handleFilterChange}
availableEventTypes={availableEventTypes}
/>
{/* Table */}
<div style={{
background: '#fff',
border: '0.5px solid #e0ddd4',
borderRadius: 12,
overflow: 'hidden',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
}}>
<EventsTable
events={events}
loading={loading}
error={error}
onRetry={fetchEvents}
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,257 @@
'use client'
/**
* QueueTab — AI 待辦隊列 Tab
* ============================
* 消費GET /api/v1/ai/governance/queue?dispatch_status=pending
*
* 內容:
* 1. SSE 連線狀態列(靜態 label
* 2. 待辦卡片清單QueueItemCard
* 3. 歷史記錄QueueHistoryTabs
* 4. 空狀態table_pending vs total=0
*
* 注意:批准/拒絕按鈕本 PR 僅 console.logHITL POST 為下一 PR
*
* @updated 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5 填入真實內容
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Wifi, Construction, Inbox } from 'lucide-react'
import { QueueItemCard, type QueueItem } from '@/components/governance/queue-item-card'
import { QueueHistoryTabs } from '@/components/governance/queue-history-tabs'
import { GlassCard } from '@/components/ui/glass-card'
// =============================================================================
// Config
// =============================================================================
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
// =============================================================================
// Types
// =============================================================================
interface QueueApiResponse {
items?: QueueItem[]
total?: number
table_pending?: boolean
succeeded?: QueueItem[]
failed?: QueueItem[]
}
// =============================================================================
// Skeleton
// =============================================================================
function QueueSkeleton() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{[0, 1, 2].map(i => (
<div key={i} style={{
background: '#fff',
border: '0.5px solid #e0ddd4',
borderRadius: 12,
padding: 20,
display: 'flex', flexDirection: 'column', gap: 10,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 70, height: 18, borderRadius: 4, background: '#e0ddd4', animation: `pulse 1.5s infinite`, animationDelay: `${i * 0.1}s` }} />
<div style={{ width: 80, height: 10, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
</div>
<div style={{ width: '70%', height: 14, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
<div style={{ width: '100%', height: 4, borderRadius: 2, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
</div>
))}
</div>
)
}
// =============================================================================
// Component
// =============================================================================
export function QueueTab() {
const t = useTranslations('governance.queue')
const [pending, setPending] = useState<QueueItem[]>([])
const [succeeded, setSucceeded] = useState<QueueItem[]>([])
const [failed, setFailed] = useState<QueueItem[]>([])
const [tablePending, setTablePending] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const fetchQueue = () => {
setLoading(true)
fetch(`${API_BASE}/api/v1/ai/governance/queue?dispatch_status=pending`)
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then((d: QueueApiResponse) => {
setPending(d.items ?? [])
setSucceeded(d.succeeded ?? [])
setFailed(d.failed ?? [])
setTablePending(d.table_pending ?? false)
setError(false)
})
.catch(() => setError(true))
.finally(() => setLoading(false))
}
useEffect(() => {
fetchQueue()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* SSE status bar (static label for this PR) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Wifi size={12} style={{ color: '#22C55E' }} />
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#22C55E',
fontWeight: 600,
letterSpacing: '0.3px',
}}>
{t('sse.label')}
</span>
</div>
{/* Error state */}
{error && (
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '20px 0' }}>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 12, color: '#87867f' }}>{t('error')}</span>
<button
onClick={fetchQueue}
style={{
padding: '6px 16px',
border: '0.5px solid #d97757',
borderRadius: 6,
background: 'transparent',
color: '#d97757',
cursor: 'pointer',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
}}
>
{t('retry')}
</button>
</div>
</GlassCard>
)}
{/* Loading */}
{loading && !error && <QueueSkeleton />}
{/* Dispatch table pending */}
{!loading && !error && tablePending && (
<GlassCard variant="subtle" padding="lg">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '24px 0' }}>
<Construction size={28} style={{ color: '#87867f' }} />
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: 'Syne, sans-serif', fontSize: 14, fontWeight: 600, color: '#141413', marginBottom: 4 }}>
{t('emptyState.noTable')}
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, color: '#87867f' }}>
{t('emptyState.noTableHint')}
</div>
</div>
</div>
</GlassCard>
)}
{/* Empty pending */}
{!loading && !error && !tablePending && pending.length === 0 && (
<GlassCard variant="subtle" padding="lg">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '24px 0' }}>
<div style={{
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(34,197,94,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Inbox size={22} style={{ color: '#22C55E' }} />
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: 'Syne, sans-serif', fontSize: 14, fontWeight: 600, color: '#141413', marginBottom: 4 }}>
{t('emptyState.noPending')}
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, color: '#87867f' }}>
{t('emptyState.noPendingHint')}
</div>
</div>
</div>
</GlassCard>
)}
{/* Pending queue cards */}
{!loading && !error && !tablePending && pending.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Section label */}
<div style={{
fontFamily: 'Syne, sans-serif',
fontSize: 12,
fontWeight: 700,
color: '#141413',
textTransform: 'uppercase',
letterSpacing: '0.8px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<span>{t('pendingSection')}</span>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 20, height: 18,
borderRadius: 4,
background: 'rgba(217,119,87,0.10)',
color: '#d97757',
fontSize: 10,
fontWeight: 700,
fontFamily: "'DM Mono', monospace",
padding: '0 5px',
}}>
{pending.length}
</span>
</div>
{pending.map(item => (
<QueueItemCard key={item.id} item={item} />
))}
</div>
)}
{/* History section */}
{!loading && !error && (succeeded.length > 0 || failed.length > 0) && (
<div style={{
background: '#fff',
border: '0.5px solid #e0ddd4',
borderRadius: 12,
overflow: 'hidden',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
}}>
{/* History header */}
<div style={{
padding: '10px 16px',
borderBottom: '0.5px solid #e0ddd4',
background: '#faf9f3',
}}>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 12,
fontWeight: 700,
color: '#141413',
textTransform: 'uppercase',
letterSpacing: '0.8px',
}}>
{t('history.title')}
</span>
</div>
<QueueHistoryTabs succeeded={succeeded} failed={failed} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
/**
* SloTab — SLO 儀表 Tab
* ======================
* 消費GET /api/v1/ai/slo + GET /api/v1/ai/governance/summary?days=30
*
* 內容:
* 1. 三 KPI 卡片水平排列mobile 堆疊)
* 2. 30d 違反次數時序圖Recharts BarChart stacked
* 3. 整體合規率副資訊
*
* @updated 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5 填入真實內容
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { ShieldCheck, AlertTriangle } from 'lucide-react'
import { SloKpiCard, type SloMetric } from '@/components/governance/slo-kpi-card'
import { SloViolationChart, type ViolationDataPoint } from '@/components/governance/slo-violation-chart'
import { GlassCard } from '@/components/ui/glass-card'
// =============================================================================
// Config
// =============================================================================
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
// =============================================================================
// Types
// =============================================================================
interface SloApiResponse {
metrics?: {
decision_accuracy?: { current: number; target: number; status: string; sparkline?: number[] }
km_growth_rate?: { current: number; target: number; status: string; sparkline?: number[] }
mcp_call_diversity?: { current: number; target: number; status: string; sparkline?: number[] }
}
overall_compliance?: number
computed_at?: string
}
interface SummaryApiResponse {
data?: ViolationDataPoint[]
event_types?: string[]
days?: number
}
// =============================================================================
// Helpers
// =============================================================================
function mapStatus(s: string): SloMetric['status'] {
if (s === 'healthy') return 'healthy'
if (s === 'warning') return 'warning'
return 'critical'
}
function buildMetrics(api: SloApiResponse): SloMetric[] {
const m = api.metrics ?? {}
const names: Array<SloMetric['name']> = ['decision_accuracy', 'km_growth_rate', 'mcp_call_diversity']
return names.map(name => {
const entry = m[name]
return {
name,
current: entry?.current ?? null,
target: entry?.target ?? 0.9,
status: mapStatus(entry?.status ?? 'warning'),
unit: '%',
sparkline: entry?.sparkline ?? [],
}
})
}
// =============================================================================
// Component
// =============================================================================
export function SloTab() {
const t = useTranslations('governance')
const [sloData, setSloData] = useState<SloApiResponse | null>(null)
const [summaryData, setSummaryData] = useState<SummaryApiResponse | null>(null)
const [sloLoading, setSloLoading] = useState(true)
const [summaryLoading, setSummaryLoading] = useState(true)
const [sloError, setSloError] = useState(false)
const [summaryError, setSummaryError] = useState(false)
// Fetch SLO
useEffect(() => {
let cancelled = false
setSloLoading(true)
fetch(`${API_BASE}/api/v1/ai/slo`)
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(d => { if (!cancelled) { setSloData(d); setSloError(false) } })
.catch(() => { if (!cancelled) setSloError(true) })
.finally(() => { if (!cancelled) setSloLoading(false) })
return () => { cancelled = true }
}, [])
// Fetch 30d summary
useEffect(() => {
let cancelled = false
setSummaryLoading(true)
fetch(`${API_BASE}/api/v1/ai/governance/summary?days=30`)
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(d => { if (!cancelled) { setSummaryData(d); setSummaryError(false) } })
.catch(() => { if (!cancelled) setSummaryError(true) })
.finally(() => { if (!cancelled) setSummaryLoading(false) })
return () => { cancelled = true }
}, [])
const metrics = sloData ? buildMetrics(sloData) : []
const compliance = sloData?.overall_compliance ?? null
const chartData: ViolationDataPoint[] = summaryData?.data ?? []
const eventTypes: string[] = summaryData?.event_types ?? []
return (
<div style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* SLO Error banner */}
{sloError && (
<GlassCard variant="subtle" padding="sm">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<AlertTriangle size={14} style={{ color: '#F59E0B', flexShrink: 0 }} />
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, color: '#87867f' }}>
{t('slo.kpi.error')}
</span>
</div>
</GlassCard>
)}
{/* Overall compliance — inline badge row */}
{!sloLoading && compliance !== null && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '5px 10px',
borderRadius: 6,
background: compliance >= 0.95 ? 'rgba(34,197,94,0.08)' : 'rgba(245,158,11,0.08)',
border: `0.5px solid ${compliance >= 0.95 ? '#22C55E40' : '#F59E0B40'}`,
}}>
<ShieldCheck size={13} style={{ color: compliance >= 0.95 ? '#22C55E' : '#F59E0B' }} />
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 13,
fontWeight: 700,
color: compliance >= 0.95 ? '#22C55E' : '#F59E0B',
}}>
{(compliance * 100).toFixed(1)}%
</span>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
{t('slo.compliance.title')}
</span>
</div>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#b8b4aa' }}>
{t('slo.compliance.target')}
</span>
</div>
)}
{/* KPI cards — horizontal on desktop, stacked on mobile */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
}}
className="slo-kpi-grid"
>
{sloLoading
? [0, 1, 2].map(i => <SloKpiCard key={i} metric={{ name: 'decision_accuracy', current: null, target: 0.9, status: 'warning' }} loading />)
: metrics.map(m => <SloKpiCard key={m.name} metric={m} />)
}
</div>
{/* Violation timeline chart */}
<SloViolationChart
data={chartData}
eventTypes={eventTypes}
loading={summaryLoading}
error={summaryError}
/>
{/* Responsive helpers */}
<style>{`
.slo-kpi-grid > * { flex: 1; min-width: 200px; }
@media (max-width: 640px) {
.slo-kpi-grid > * { flex: 0 0 100%; min-width: 0; }
}
`}</style>
</div>
)
}