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
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:
49
apps/web/src/app/[locale]/governance/page.tsx
Normal file
49
apps/web/src/app/[locale]/governance/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
apps/web/src/app/[locale]/governance/tabs/events-tab.tsx
Normal file
136
apps/web/src/app/[locale]/governance/tabs/events-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
257
apps/web/src/app/[locale]/governance/tabs/queue-tab.tsx
Normal file
257
apps/web/src/app/[locale]/governance/tabs/queue-tab.tsx
Normal 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.log,HITL 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>
|
||||
)
|
||||
}
|
||||
194
apps/web/src/app/[locale]/governance/tabs/slo-tab.tsx
Normal file
194
apps/web/src/app/[locale]/governance/tabs/slo-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user