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,207 @@
'use client'
/**
* EventDetailDrawer — 事件詳情 inline 展開
* ==========================================
* 三欄佈局JSON tree / 修復建議 / 派遣記錄
* 背景 #faf9f3左側 4px 豎條依嚴重度色
*
* @created 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5
*/
import { useTranslations } from 'next-intl'
import type { GovernanceEvent } from './events-table'
// =============================================================================
// Severity colour
// =============================================================================
const SEVERITY_COLOR: Record<string, string> = {
critical: '#FF3300',
warning: '#F59E0B',
info: '#4A90D9',
}
// =============================================================================
// Component
// =============================================================================
interface EventDetailDrawerProps {
event: GovernanceEvent
}
export function EventDetailDrawer({ event }: EventDetailDrawerProps) {
const t = useTranslations('governance.events.detail')
const accentColor = SEVERITY_COLOR[event.severity ?? 'info'] ?? '#4A90D9'
const jsonString = JSON.stringify(event.raw_data ?? { id: event.id, event_type: event.event_type }, null, 2)
return (
<tr>
<td
colSpan={5}
style={{ padding: 0 }}
>
<div style={{
background: '#faf9f3',
borderLeft: `4px solid ${accentColor}`,
borderBottom: '0.5px solid #e0ddd4',
padding: '16px 20px',
}}>
{/* Three-column grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 16,
}}
className="event-detail-grid"
>
{/* Column 1: JSON tree */}
<div>
<div style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 600,
color: '#87867f',
textTransform: 'uppercase',
letterSpacing: '0.6px',
marginBottom: 8,
}}>
{t('rawData')}
</div>
<pre style={{
fontFamily: "'JetBrains Mono', 'DM Mono', monospace",
fontSize: 11,
color: '#141413',
background: 'rgba(255,255,255,0.7)',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
padding: '10px 12px',
overflowX: 'auto',
margin: 0,
lineHeight: 1.6,
maxHeight: 200,
overflowY: 'auto',
}}>
<code>{jsonString}</code>
</pre>
</div>
{/* Column 2: Remediation */}
<div>
<div style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 600,
color: '#87867f',
textTransform: 'uppercase',
letterSpacing: '0.6px',
marginBottom: 8,
}}>
{t('remediation')}
</div>
<div style={{
background: 'rgba(255,255,255,0.7)',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
padding: '10px 12px',
minHeight: 60,
}}>
{event.remediation ? (
<p style={{
fontFamily: "'DM Mono', monospace",
fontSize: 12,
color: '#141413',
lineHeight: 1.6,
margin: 0,
}}>
{event.remediation}
</p>
) : (
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#b8b4aa',
fontStyle: 'italic',
}}>
{t('noRemediation')}
</span>
)}
</div>
</div>
{/* Column 3: Dispatch log */}
<div>
<div style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 600,
color: '#87867f',
textTransform: 'uppercase',
letterSpacing: '0.6px',
marginBottom: 8,
}}>
{t('dispatch')}
</div>
<div style={{
background: 'rgba(255,255,255,0.7)',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
padding: '10px 12px',
minHeight: 60,
}}>
{event.dispatch_records && event.dispatch_records.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{event.dispatch_records.map((rec, idx) => (
<div key={idx} style={{
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#141413',
display: 'flex',
alignItems: 'flex-start',
gap: 6,
lineHeight: 1.5,
}}>
<span style={{ color: '#87867f', flexShrink: 0 }}>
{new Date(rec.created_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' })}
</span>
<span>{rec.action}</span>
<span style={{
marginLeft: 'auto', flexShrink: 0,
fontSize: 10,
color: rec.status === 'succeeded' ? '#22C55E'
: rec.status === 'failed' ? '#FF3300'
: '#87867f',
}}>
{rec.status}
</span>
</div>
))}
</div>
) : (
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#b8b4aa',
fontStyle: 'italic',
}}>
{t('noDispatch')}
</span>
)}
</div>
</div>
</div>
{/* Responsive: stack on mobile via inline style tag override */}
<style>{`
@media (max-width: 768px) {
.event-detail-grid {
grid-template-columns: 1fr !important;
}
}
`}</style>
</div>
</td>
</tr>
)
}

View File

@@ -0,0 +1,292 @@
'use client'
/**
* EventsFilterBar — 治理事件篩選列
* ==================================
* event_type 多選 / 時間範圍 / status / severity / 清除全部
* GlassCard variant="subtle" padding="sm"
*
* @created 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5
*/
import { useState, useRef, useEffect } from 'react'
import { Filter, Calendar, ChevronDown, X } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { GlassCard } from '@/components/ui/glass-card'
// =============================================================================
// Types
// =============================================================================
export interface EventsFilter {
eventTypes: string[]
status: 'all' | 'resolved' | 'unresolved'
severity: 'all' | 'critical' | 'warning' | 'info'
dateFrom: string
dateTo: string
}
interface EventsFilterBarProps {
filter: EventsFilter
onChange: (filter: EventsFilter) => void
availableEventTypes?: string[]
}
// =============================================================================
// Helpers
// =============================================================================
const SEVERITY_COLOR: Record<string, string> = {
critical: '#FF3300',
warning: '#F59E0B',
info: '#4A90D9',
}
// =============================================================================
// Multi-select combobox
// =============================================================================
interface MultiSelectProps {
options: string[]
selected: string[]
onChange: (values: string[]) => void
placeholder: string
labelMap?: Record<string, string>
}
function MultiSelect({ options, selected, onChange, placeholder, labelMap }: MultiSelectProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const toggle = (v: string) => {
onChange(selected.includes(v) ? selected.filter(x => x !== v) : [...selected, v])
}
return (
<div ref={ref} style={{ position: 'relative' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 8px',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
background: selected.length > 0 ? 'rgba(217,119,87,0.06)' : '#fff',
cursor: 'pointer',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: selected.length > 0 ? '#d97757' : '#87867f',
transition: 'all 0.12s',
whiteSpace: 'nowrap',
}}
aria-haspopup="listbox"
aria-expanded={open}
>
<Filter size={11} />
{selected.length > 0 ? `${selected.length} 已選` : placeholder}
<ChevronDown size={10} style={{ marginLeft: 2, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{open && (
<div
role="listbox"
style={{
position: 'absolute', top: '100%', left: 0, zIndex: 50,
marginTop: 4,
background: '#fff',
border: '0.5px solid #e0ddd4',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.10)',
minWidth: 160,
padding: 4,
}}
>
{options.map(opt => (
<button
key={opt}
role="option"
aria-selected={selected.includes(opt)}
onClick={() => toggle(opt)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
width: '100%', padding: '5px 10px',
background: selected.includes(opt) ? 'rgba(217,119,87,0.06)' : 'transparent',
border: 'none', cursor: 'pointer', borderRadius: 4,
fontFamily: "'DM Mono', monospace", fontSize: 11,
color: selected.includes(opt) ? '#d97757' : '#141413',
textAlign: 'left',
}}
>
<span style={{
width: 12, height: 12, borderRadius: 3,
border: `1.5px solid ${selected.includes(opt) ? '#d97757' : '#d0cec7'}`,
background: selected.includes(opt) ? '#d97757' : 'transparent',
flexShrink: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
{selected.includes(opt) && (
<svg width="8" height="6" viewBox="0 0 8 6" fill="none">
<path d="M1 3l2 2 4-4" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
{labelMap?.[opt] ?? opt}
</button>
))}
</div>
)}
</div>
)
}
// =============================================================================
// Component
// =============================================================================
export function EventsFilterBar({ filter, onChange, availableEventTypes = [] }: EventsFilterBarProps) {
const t = useTranslations('governance.events.filter')
const tType = useTranslations('governance.events.eventType')
const eventTypeLabels: Record<string, string> = {
slo_breach: tType('slo_breach'),
accuracy_drop: tType('accuracy_drop'),
km_stall: tType('km_stall'),
mcp_failure: tType('mcp_failure'),
trust_degradation: tType('trust_degradation'),
}
const hasActiveFilter =
filter.eventTypes.length > 0 ||
filter.status !== 'all' ||
filter.severity !== 'all' ||
filter.dateFrom !== '' ||
filter.dateTo !== ''
const clearAll = () => onChange({
eventTypes: [],
status: 'all',
severity: 'all',
dateFrom: '',
dateTo: '',
})
const selectStyle = {
padding: '4px 8px',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
background: '#fff',
cursor: 'pointer',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#141413',
appearance: 'none' as const,
WebkitAppearance: 'none' as const,
paddingRight: 24,
}
const dateInputStyle = {
padding: '4px 8px',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
background: '#fff',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#141413',
cursor: 'pointer',
}
return (
<GlassCard variant="subtle" padding="sm">
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
{/* Event type multi-select */}
<MultiSelect
options={availableEventTypes.length > 0 ? availableEventTypes : Object.keys(eventTypeLabels)}
selected={filter.eventTypes}
onChange={v => onChange({ ...filter, eventTypes: v })}
placeholder={t('placeholder')}
labelMap={eventTypeLabels}
/>
{/* Date from */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Calendar size={11} style={{ color: '#87867f', flexShrink: 0 }} />
<input
type="date"
value={filter.dateFrom}
onChange={e => onChange({ ...filter, dateFrom: e.target.value })}
style={dateInputStyle}
aria-label={t('from')}
/>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}></span>
<input
type="date"
value={filter.dateTo}
onChange={e => onChange({ ...filter, dateTo: e.target.value })}
style={dateInputStyle}
aria-label={t('to')}
/>
</div>
{/* Status select */}
<div style={{ position: 'relative' }}>
<select
value={filter.status}
onChange={e => onChange({ ...filter, status: e.target.value as EventsFilter['status'] })}
style={selectStyle}
aria-label={t('status')}
>
<option value="all">{t('allStatuses')}</option>
<option value="resolved">{t('resolved')}</option>
<option value="unresolved">{t('unresolved')}</option>
</select>
<ChevronDown size={10} style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', color: '#87867f', pointerEvents: 'none' }} />
</div>
{/* Severity select */}
<div style={{ position: 'relative' }}>
<select
value={filter.severity}
onChange={e => onChange({ ...filter, severity: e.target.value as EventsFilter['severity'] })}
style={{
...selectStyle,
color: filter.severity !== 'all' ? SEVERITY_COLOR[filter.severity] : '#141413',
}}
aria-label={t('severity')}
>
<option value="all">{t('allSeverities')}</option>
<option value="critical">{t('critical')}</option>
<option value="warning">{t('warning')}</option>
<option value="info">{t('info')}</option>
</select>
<ChevronDown size={10} style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', color: '#87867f', pointerEvents: 'none' }} />
</div>
{/* Clear all */}
{hasActiveFilter && (
<button
onClick={clearAll}
style={{
display: 'flex', alignItems: 'center', gap: 4,
background: 'none', border: 'none', cursor: 'pointer',
fontFamily: "'DM Mono', monospace", fontSize: 11,
color: '#d97757', marginLeft: 4,
padding: '4px 0',
}}
>
<X size={11} />
{t('clearAll')}
</button>
)}
</div>
</GlassCard>
)
}

View File

@@ -0,0 +1,367 @@
'use client'
/**
* EventsTable — 治理事件表格
* ===========================
* 欄位event_type badge / triggered_at / status / impact / expand
* 展開行EventDetailDrawerinline非側滑
* 分頁:底部 offset每頁 20 筆
*
* @created 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5
*/
import { useState } from 'react'
import { ChevronDown, ChevronLeft, ChevronRight, AlertTriangle, ShieldCheck } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { StatusOrb } from '@/components/ui/status-orb'
import { EventDetailDrawer } from './event-detail-drawer'
// =============================================================================
// Types
// =============================================================================
export interface DispatchRecord {
created_at: string
action: string
status: 'pending' | 'succeeded' | 'failed'
}
export interface GovernanceEvent {
id: string
event_type: string
triggered_at: string
status: 'resolved' | 'unresolved'
severity?: 'critical' | 'warning' | 'info'
impact_summary?: string
raw_data?: Record<string, unknown>
remediation?: string
dispatch_records?: DispatchRecord[]
}
interface EventsTableProps {
events: GovernanceEvent[]
loading?: boolean
error?: boolean
onRetry?: () => void
total: number
page: number
pageSize: number
onPageChange: (page: number) => void
}
// =============================================================================
// Styles
// =============================================================================
const EVENT_TYPE_COLORS: Record<string, { bg: string; text: string }> = {
slo_breach: { bg: 'rgba(255,51,0,0.08)', text: '#FF3300' },
accuracy_drop: { bg: 'rgba(245,158,11,0.10)', text: '#d97010' },
km_stall: { bg: 'rgba(74,144,217,0.10)', text: '#2563EB' },
mcp_failure: { bg: 'rgba(139,92,246,0.10)', text: '#7C3AED' },
trust_degradation: { bg: 'rgba(236,72,153,0.10)', text: '#DB2777' },
}
function getEventTypeStyle(type: string) {
return EVENT_TYPE_COLORS[type] ?? { bg: 'rgba(135,134,127,0.10)', text: '#87867f' }
}
// =============================================================================
// Skeleton rows
// =============================================================================
function SkeletonRow() {
return (
<tr style={{ borderBottom: '0.5px solid #e0ddd4' }}>
{[80, 120, 60, 180, 40].map((w, i) => (
<td key={i} style={{ padding: '10px 12px' }}>
<div style={{ width: w, height: 12, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite', animationDelay: `${i * 0.08}s` }} />
</td>
))}
</tr>
)
}
// =============================================================================
// Component
// =============================================================================
export function EventsTable({
events, loading = false, error = false, onRetry,
total, page, pageSize, onPageChange,
}: EventsTableProps) {
const t = useTranslations('governance.events')
const tType = useTranslations('governance.events.eventType')
const [expandedId, setExpandedId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const formatDate = (iso: string) =>
new Date(iso).toLocaleString('zh-TW', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
const thStyle: React.CSSProperties = {
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 600,
color: '#87867f',
textTransform: 'uppercase',
letterSpacing: '0.6px',
padding: '8px 12px',
textAlign: 'left',
borderBottom: '0.5px solid #e0ddd4',
whiteSpace: 'nowrap',
}
return (
<div style={{ overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '15%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: 'auto' }} />
<col style={{ width: '52px' }} />
</colgroup>
<thead>
<tr style={{ background: '#faf9f3' }}>
<th style={thStyle}>{t('column.eventType')}</th>
<th style={thStyle}>{t('column.triggeredAt')}</th>
<th style={thStyle}>{t('column.status')}</th>
<th style={thStyle}>{t('column.impact')}</th>
<th style={{ ...thStyle, textAlign: 'center' }}>{t('column.actions')}</th>
</tr>
</thead>
<tbody>
{/* Loading state */}
{loading && Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} />)}
{/* Error state */}
{!loading && error && (
<tr>
<td colSpan={5}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', padding: '40px 20px', gap: 12,
}}>
<AlertTriangle size={24} style={{ color: '#F59E0B' }} />
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 12, color: '#87867f' }}>
{t('errorState')}
</span>
{onRetry && (
<button
onClick={onRetry}
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>
</td>
</tr>
)}
{/* Empty state */}
{!loading && !error && events.length === 0 && (
<tr>
<td colSpan={5}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', padding: '40px 20px', gap: 12,
}}>
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: '#22C55E1A',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ShieldCheck size={20} style={{ color: '#22C55E' }} />
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: 'Syne, sans-serif', fontSize: 14, fontWeight: 600, color: '#141413', marginBottom: 4 }}>
{t('emptyState')}
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, color: '#87867f' }}>
{t('emptyStateHint')}
</div>
</div>
</div>
</td>
</tr>
)}
{/* Data rows */}
{!loading && !error && events.map(event => {
const isExpanded = expandedId === event.id
const typeStyle = getEventTypeStyle(event.event_type)
return (
<>
<tr
key={event.id}
style={{
borderBottom: isExpanded ? 'none' : '0.5px solid #e0ddd4',
transition: 'background 0.12s',
background: isExpanded ? 'rgba(217,119,87,0.03)' : 'transparent',
}}
onMouseEnter={e => { if (!isExpanded) (e.currentTarget as HTMLElement).style.background = 'rgba(217,119,87,0.04)' }}
onMouseLeave={e => { if (!isExpanded) (e.currentTarget as HTMLElement).style.background = 'transparent' }}
>
{/* event_type badge */}
<td style={{ padding: '10px 12px' }}>
<span style={{
display: 'inline-block',
padding: '2px 7px',
borderRadius: 4,
background: typeStyle.bg,
color: typeStyle.text,
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.3px',
whiteSpace: 'nowrap',
}}>
{tType(event.event_type as Parameters<typeof tType>[0]) ?? event.event_type}
</span>
</td>
{/* triggered_at */}
<td style={{ padding: '10px 12px', fontFamily: "'DM Mono', monospace", fontSize: 11, color: '#141413', whiteSpace: 'nowrap' }}>
{formatDate(event.triggered_at)}
</td>
{/* status */}
<td style={{ padding: '10px 12px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<StatusOrb
status={event.status === 'resolved' ? 'healthy' : 'warning'}
size="xs"
pulse={event.status === 'unresolved'}
/>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, color: '#141413' }}>
{t(`status.${event.status}`)}
</span>
</span>
</td>
{/* impact */}
<td style={{
padding: '10px 12px',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#87867f',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{event.impact_summary ?? '—'}
</td>
{/* expand */}
<td style={{ padding: '10px 12px', textAlign: 'center' }}>
<button
onClick={() => setExpandedId(isExpanded ? null : event.id)}
aria-label={isExpanded ? t('collapse') : t('expand')}
aria-expanded={isExpanded}
style={{
background: 'none', border: 'none', cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: 4,
borderRadius: 4,
transition: 'background 0.12s',
color: isExpanded ? '#d97757' : '#87867f',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(217,119,87,0.08)')}
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
>
<ChevronDown
size={14}
style={{
transform: isExpanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.18s',
}}
/>
</button>
</td>
</tr>
{/* Inline expand drawer */}
{isExpanded && <EventDetailDrawer key={`detail-${event.id}`} event={event} />}
</>
)
})}
</tbody>
</table>
</div>
{/* Pagination */}
{!loading && !error && total > 0 && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
borderTop: '0.5px solid #e0ddd4',
background: '#faf9f3',
}}>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
{t('perPage')} {total}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
aria-label={t('prevPage')}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28,
border: '0.5px solid #e0ddd4',
borderRadius: 6,
background: '#fff',
cursor: page <= 1 ? 'not-allowed' : 'pointer',
opacity: page <= 1 ? 0.4 : 1,
color: '#141413',
}}
>
<ChevronLeft size={12} />
</button>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, color: '#141413' }}>
{page} / {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
aria-label={t('nextPage')}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28,
border: '0.5px solid #e0ddd4',
borderRadius: 6,
background: '#fff',
cursor: page >= totalPages ? 'not-allowed' : 'pointer',
opacity: page >= totalPages ? 0.4 : 1,
color: '#141413',
}}
>
<ChevronRight size={12} />
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
/**
* QueueHistoryTabs — succeeded / failed 兩子 tab 歷史記錄
* =========================================================
* 表格顯示:事件類型 / 操作 / 時間 / 狀態
*
* @created 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5
*/
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import type { QueueItem } from './queue-item-card'
// =============================================================================
// Types
// =============================================================================
interface QueueHistoryTabsProps {
succeeded: QueueItem[]
failed: QueueItem[]
}
// =============================================================================
// Component
// =============================================================================
export function QueueHistoryTabs({ succeeded, failed }: QueueHistoryTabsProps) {
const t = useTranslations('governance.queue')
const tType = useTranslations('governance.events.eventType')
const [active, setActive] = useState<'succeeded' | 'failed'>('succeeded')
const rows = active === 'succeeded' ? succeeded : failed
const statusColor = active === 'succeeded' ? '#22C55E' : '#FF3300'
const thStyle: React.CSSProperties = {
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 600,
color: '#87867f',
textTransform: 'uppercase',
letterSpacing: '0.5px',
padding: '7px 12px',
textAlign: 'left',
borderBottom: '0.5px solid #e0ddd4',
background: '#faf9f3',
}
return (
<div>
{/* Sub-tab bar */}
<div style={{ display: 'flex', borderBottom: '0.5px solid #e0ddd4', marginBottom: 0 }}>
{(['succeeded', 'failed'] as const).map(tab => {
const isActive = active === tab
const count = tab === 'succeeded' ? succeeded.length : failed.length
return (
<button
key={tab}
onClick={() => setActive(tab)}
style={{
padding: '7px 14px',
fontSize: 11,
fontWeight: isActive ? 600 : 500,
color: isActive ? (tab === 'succeeded' ? '#22C55E' : '#FF3300') : '#87867f',
borderBottom: `2px solid ${isActive ? (tab === 'succeeded' ? '#22C55E' : '#FF3300') : 'transparent'}`,
background: 'transparent',
border: 'none',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: isActive ? (tab === 'succeeded' ? '#22C55E' : '#FF3300') : 'transparent',
cursor: 'pointer',
fontFamily: "'DM Mono', monospace",
display: 'flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.12s',
}}
>
{t(`history.${tab}`)}
{count > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 18, height: 16,
borderRadius: 4,
background: isActive ? (tab === 'succeeded' ? 'rgba(34,197,94,0.12)' : 'rgba(255,51,0,0.12)') : '#e0ddd4',
color: isActive ? (tab === 'succeeded' ? '#22C55E' : '#FF3300') : '#87867f',
fontSize: 9,
fontWeight: 700,
padding: '0 4px',
}}>
{count}
</span>
)}
</button>
)
})}
</div>
{/* Table */}
{rows.length === 0 ? (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '32px 20px',
fontFamily: "'DM Mono', monospace", fontSize: 12, color: '#87867f',
}}>
{t('history.empty')}
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={thStyle}>{t('column.eventType')}</th>
<th style={thStyle}>{t('column.proposedAction')}</th>
<th style={thStyle}>{t('column.createdAt')}</th>
<th style={thStyle}>{t('column.dispatchStatus')}</th>
</tr>
</thead>
<tbody>
{rows.map(item => (
<tr
key={item.id}
style={{ borderBottom: '0.5px solid #e0ddd4' }}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(217,119,87,0.03)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<td style={{ padding: '8px 12px' }}>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#87867f',
background: '#f0ede6',
padding: '2px 6px',
borderRadius: 3,
}}>
{tType(item.event_type as Parameters<typeof tType>[0]) ?? item.event_type}
</span>
</td>
<td style={{
padding: '8px 12px',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#141413',
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{item.proposed_action}
</td>
<td style={{
padding: '8px 12px',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#87867f',
whiteSpace: 'nowrap',
}}>
{new Date(item.created_at).toLocaleString('zh-TW', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})}
</td>
<td style={{ padding: '8px 12px' }}>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 600,
color: statusColor,
}}>
{item.dispatch_status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,225 @@
'use client'
/**
* QueueItemCard — HITL 待辦卡片
* ================================
* event_type badge + 時間 + proposed_action + playbook_trust 進度條
* 批准/拒絕按鈕(本 PR 僅 console.logHITL POST 為下一 PR
*
* @created 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5
*/
import { CheckCircle, XCircle } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { GlassCard } from '@/components/ui/glass-card'
// =============================================================================
// Types
// =============================================================================
export interface QueueItem {
id: string
event_type: string
created_at: string
proposed_action: string
playbook_trust: number // 0100
dispatch_status: 'pending' | 'approved' | 'rejected' | 'expired'
}
interface QueueItemCardProps {
item: QueueItem
}
// =============================================================================
// Helpers
// =============================================================================
const EVENT_TYPE_COLORS: Record<string, { bg: string; text: string }> = {
slo_breach: { bg: 'rgba(255,51,0,0.08)', text: '#FF3300' },
accuracy_drop: { bg: 'rgba(245,158,11,0.10)', text: '#d97010' },
km_stall: { bg: 'rgba(74,144,217,0.10)', text: '#2563EB' },
mcp_failure: { bg: 'rgba(139,92,246,0.10)', text: '#7C3AED' },
trust_degradation: { bg: 'rgba(236,72,153,0.10)', text: '#DB2777' },
}
function trustColor(trust: number): string {
if (trust >= 80) return '#22C55E'
if (trust >= 50) return '#F59E0B'
return '#FF3300'
}
// =============================================================================
// Component
// =============================================================================
export function QueueItemCard({ item }: QueueItemCardProps) {
const t = useTranslations('governance.queue')
const tType = useTranslations('governance.events.eventType')
const typeStyle = EVENT_TYPE_COLORS[item.event_type] ?? { bg: 'rgba(135,134,127,0.10)', text: '#87867f' }
const color = trustColor(item.playbook_trust)
const handleApprove = () => {
console.log('[HITL] Approve queue item', { id: item.id, proposed_action: item.proposed_action })
}
const handleReject = () => {
console.log('[HITL] Reject queue item', { id: item.id, proposed_action: item.proposed_action })
}
const formattedTime = new Date(item.created_at).toLocaleString('zh-TW', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
return (
<GlassCard variant="elevated" hoverable padding="md" className="w-full">
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
{/* Left: content */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Top row: badge + time */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{
display: 'inline-block',
padding: '2px 7px',
borderRadius: 4,
background: typeStyle.bg,
color: typeStyle.text,
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.3px',
flexShrink: 0,
}}>
{tType(item.event_type as Parameters<typeof tType>[0]) ?? item.event_type}
</span>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#87867f',
}}>
{formattedTime}
</span>
</div>
{/* Proposed action */}
<div style={{
fontFamily: 'Syne, sans-serif',
fontSize: 13,
fontWeight: 600,
color: '#141413',
lineHeight: 1.4,
marginBottom: 10,
wordBreak: 'break-word',
}}>
{item.proposed_action}
</div>
{/* Trust progress bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#87867f',
flexShrink: 0,
whiteSpace: 'nowrap',
}}>
{t('column.playbookTrust')}
</span>
<div style={{
flex: 1,
height: 4,
borderRadius: 2,
background: '#e0ddd4',
overflow: 'hidden',
}}>
<div style={{
height: '100%',
width: `${Math.min(100, Math.max(0, item.playbook_trust))}%`,
background: color,
borderRadius: 2,
transition: 'width 0.4s ease',
}} />
</div>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color,
fontWeight: 600,
flexShrink: 0,
minWidth: 32,
textAlign: 'right',
}}>
{item.playbook_trust}%
</span>
</div>
</div>
{/* Right: action buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button
onClick={handleApprove}
title={t('action.approveTitle')}
aria-label={t('action.approveTitle')}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '6px 10px',
borderRadius: 6,
border: '0.5px solid #22C55E40',
background: 'rgba(34,197,94,0.08)',
color: '#22C55E',
cursor: 'pointer',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
fontWeight: 600,
transition: 'all 0.12s',
whiteSpace: 'nowrap',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(34,197,94,0.15)'
e.currentTarget.style.borderColor = '#22C55E80'
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(34,197,94,0.08)'
e.currentTarget.style.borderColor = '#22C55E40'
}}
>
<CheckCircle size={13} />
{t('action.approve')}
</button>
<button
onClick={handleReject}
title={t('action.rejectTitle')}
aria-label={t('action.rejectTitle')}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '6px 10px',
borderRadius: 6,
border: '0.5px solid #FF330040',
background: 'rgba(255,51,0,0.08)',
color: '#FF3300',
cursor: 'pointer',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
fontWeight: 600,
transition: 'all 0.12s',
whiteSpace: 'nowrap',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255,51,0,0.15)'
e.currentTarget.style.borderColor = '#FF330080'
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(255,51,0,0.08)'
e.currentTarget.style.borderColor = '#FF330040'
}}
>
<XCircle size={13} />
{t('action.reject')}
</button>
</div>
</div>
</GlassCard>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
/**
* SloKpiCard — SLO 單指標卡片
* ============================
* Nothing.tech × Anthropic Warmth 設計語言
*
* 特點:
* - Syne 大值字體 28px fw-700
* - StatusOrb 右上角狀態指示
* - 7d sparklineRecharts LineChart 80×24px無座標軸
* - 狀態色healthy=#22C55E, warning=#F59E0B, critical=#FF3300
*
* @created 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5
*/
import { LineChart, Line, ResponsiveContainer } from 'recharts'
import { StatusOrb, type StatusType } from '@/components/ui/status-orb'
import { GlassCard } from '@/components/ui/glass-card'
import { useTranslations } from 'next-intl'
// =============================================================================
// Types
// =============================================================================
export interface SloMetric {
name: 'decision_accuracy' | 'km_growth_rate' | 'mcp_call_diversity'
current: number | null
target: number
status: 'healthy' | 'warning' | 'critical'
unit?: string
sparkline?: number[] // 7 points, most recent last
}
interface SloKpiCardProps {
metric: SloMetric
loading?: boolean
}
// =============================================================================
// Status colour map
// =============================================================================
const statusColor: Record<SloMetric['status'], string> = {
healthy: '#22C55E',
warning: '#F59E0B',
critical: '#FF3300',
}
// =============================================================================
// Skeleton
// =============================================================================
function KpiSkeleton() {
return (
<GlassCard variant="elevated" padding="md" className="min-w-0">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ width: 80, height: 10, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
<div style={{ width: 60, height: 28, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
<div style={{ width: 100, height: 8, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
</div>
</GlassCard>
)
}
// =============================================================================
// Component
// =============================================================================
export function SloKpiCard({ metric, loading = false }: SloKpiCardProps) {
const t = useTranslations('governance.slo.kpi')
if (loading) return <KpiSkeleton />
const color = statusColor[metric.status]
const orbStatus: StatusType = metric.status === 'healthy' ? 'healthy'
: metric.status === 'warning' ? 'warning'
: 'critical'
const formattedValue = metric.current == null
? '--'
: metric.unit === '%'
? `${(metric.current * 100).toFixed(1)}%`
: metric.current.toFixed(2)
const formattedTarget = metric.unit === '%'
? `${(metric.target * 100).toFixed(0)}%`
: metric.target.toFixed(2)
const sparkData = (metric.sparkline ?? Array(7).fill(0)).map((v, i) => ({ i, v }))
return (
<GlassCard variant="elevated" padding="md" className="min-w-0 flex-1">
{/* Header row: metric name + status orb */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 11,
fontWeight: 600,
color: '#87867f',
textTransform: 'uppercase',
letterSpacing: '0.6px',
}}>
{t(metric.name)}
</span>
<StatusOrb status={orbStatus} size="sm" pulse={orbStatus !== 'healthy'} glow />
</div>
{/* Big value */}
<div style={{
fontFamily: 'Syne, sans-serif',
fontSize: 28,
fontWeight: 700,
color,
lineHeight: 1,
marginBottom: 4,
letterSpacing: '-0.5px',
}}>
{formattedValue}
</div>
{/* Target + sparkline row */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#87867f',
}}>
{t('target')} {formattedTarget}
</span>
{/* Sparkline 80×24px */}
<div style={{ width: 80, height: 24 }} aria-label={t('sparkline')}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={sparkData} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
<Line
type="monotone"
dataKey="v"
stroke={color}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</GlassCard>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
/**
* SloViolationChart — 30d 違反事件時序 BarChart
* ==============================================
* Recharts BarChart stacked每 event_type 一色
* X 軸DD/MM 日期Y 軸count
*
* @created 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5
*/
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
CartesianGrid,
} from 'recharts'
import { useTranslations } from 'next-intl'
import { GlassCard } from '@/components/ui/glass-card'
import { AlertTriangle } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
export interface ViolationDataPoint {
date: string // ISO date string YYYY-MM-DD
[eventType: string]: string | number
}
interface SloViolationChartProps {
data: ViolationDataPoint[]
eventTypes: string[]
loading?: boolean
error?: boolean
}
// =============================================================================
// Colour palette for event types (up to 6)
// =============================================================================
const EVENT_TYPE_COLORS = [
'#d97757', // accent coral
'#4A90D9', // blue
'#22C55E', // green
'#F59E0B', // amber
'#8B5CF6', // violet
'#EC4899', // pink
]
// =============================================================================
// Custom Tooltip
// =============================================================================
function CustomTooltip({ active, payload, label }: {
active?: boolean
payload?: Array<{ name: string; value: number; color: string }>
label?: string
}) {
if (!active || !payload?.length) return null
return (
<div style={{
background: '#fff',
border: '0.5px solid #e0ddd4',
borderRadius: 8,
padding: '8px 12px',
boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
}}>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', marginBottom: 4 }}>
{label}
</div>
{payload.map(entry => (
<div key={entry.name} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#141413', fontFamily: "'DM Mono', monospace" }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: entry.color, flexShrink: 0 }} />
<span style={{ color: '#87867f' }}>{entry.name}</span>
<span style={{ fontWeight: 600, marginLeft: 'auto', paddingLeft: 8 }}>{entry.value}</span>
</div>
))}
</div>
)
}
// =============================================================================
// Skeleton
// =============================================================================
function ChartSkeleton() {
return (
<div style={{ height: 200, display: 'flex', alignItems: 'flex-end', gap: 4, padding: '16px 0 8px' }}>
{Array.from({ length: 15 }).map((_, i) => (
<div
key={i}
style={{
flex: 1,
background: '#e0ddd4',
borderRadius: '2px 2px 0 0',
height: `${20 + Math.sin(i * 0.8) * 40 + 40}px`,
animation: 'pulse 1.5s infinite',
animationDelay: `${i * 0.05}s`,
}}
/>
))}
</div>
)
}
// =============================================================================
// Component
// =============================================================================
export function SloViolationChart({ data, eventTypes, loading = false, error = false }: SloViolationChartProps) {
const t = useTranslations('governance.slo.chart')
const formattedData = data.map(d => ({
...d,
label: new Date(d.date).toLocaleDateString('zh-TW', { month: '2-digit', day: '2-digit' }),
}))
return (
<GlassCard variant="default" padding="lg">
{/* Title */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 13,
fontWeight: 700,
color: '#141413',
letterSpacing: '0.3px',
}}>
{t('title')}
</span>
</div>
{/* States */}
{loading && <ChartSkeleton />}
{error && !loading && (
<div style={{ height: 180, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<AlertTriangle size={20} style={{ color: '#F59E0B' }} />
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 12, color: '#87867f' }}>{t('error')}</span>
</div>
)}
{!loading && !error && data.length === 0 && (
<div style={{ height: 180, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#22C55E22', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#22C55E' }} />
</div>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 12, color: '#87867f' }}>{t('empty')}</span>
</div>
)}
{!loading && !error && data.length > 0 && (
<ResponsiveContainer width="100%" height={180}>
<BarChart data={formattedData} margin={{ top: 4, right: 8, bottom: 0, left: -16 }} barSize={6}>
<CartesianGrid vertical={false} stroke="#e0ddd4" strokeWidth={0.5} />
<XAxis
dataKey="label"
tick={{ fontFamily: "'DM Mono', monospace", fontSize: 9, fill: '#87867f' }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontFamily: "'DM Mono', monospace", fontSize: 9, fill: '#87867f' }}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', paddingTop: 8 }}
iconSize={8}
iconType="square"
/>
{eventTypes.map((et, idx) => (
<Bar
key={et}
dataKey={et}
stackId="violations"
fill={EVENT_TYPE_COLORS[idx % EVENT_TYPE_COLORS.length]}
radius={idx === eventTypes.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
)}
</GlassCard>
)
}

View File

@@ -86,6 +86,7 @@ const NAV_SECTIONS: NavSection[] = [
{ id: 'operations', href: '/operations', labelKey: 'operations', Icon: Package },
{ id: 'security-compliance', href: '/security-compliance', labelKey: 'securityCompliance',Icon: Shield },
{ id: 'knowledge', href: '/knowledge', labelKey: 'knowledge', Icon: BookOpen },
{ id: 'governance', href: '/governance', labelKey: 'governance', Icon: ShieldCheck },
],
},
{