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:
207
apps/web/src/components/governance/event-detail-drawer.tsx
Normal file
207
apps/web/src/components/governance/event-detail-drawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
292
apps/web/src/components/governance/events-filter-bar.tsx
Normal file
292
apps/web/src/components/governance/events-filter-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
367
apps/web/src/components/governance/events-table.tsx
Normal file
367
apps/web/src/components/governance/events-table.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* EventsTable — 治理事件表格
|
||||
* ===========================
|
||||
* 欄位:event_type badge / triggered_at / status / impact / expand
|
||||
* 展開行:EventDetailDrawer(inline,非側滑)
|
||||
* 分頁:底部 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>
|
||||
)
|
||||
}
|
||||
181
apps/web/src/components/governance/queue-history-tabs.tsx
Normal file
181
apps/web/src/components/governance/queue-history-tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
225
apps/web/src/components/governance/queue-item-card.tsx
Normal file
225
apps/web/src/components/governance/queue-item-card.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* QueueItemCard — HITL 待辦卡片
|
||||
* ================================
|
||||
* event_type badge + 時間 + proposed_action + playbook_trust 進度條
|
||||
* 批准/拒絕按鈕(本 PR 僅 console.log,HITL 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 // 0–100
|
||||
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>
|
||||
)
|
||||
}
|
||||
150
apps/web/src/components/governance/slo-kpi-card.tsx
Normal file
150
apps/web/src/components/governance/slo-kpi-card.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SloKpiCard — SLO 單指標卡片
|
||||
* ============================
|
||||
* Nothing.tech × Anthropic Warmth 設計語言
|
||||
*
|
||||
* 特點:
|
||||
* - Syne 大值字體 28px fw-700
|
||||
* - StatusOrb 右上角狀態指示
|
||||
* - 7d sparkline(Recharts 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>
|
||||
)
|
||||
}
|
||||
194
apps/web/src/components/governance/slo-violation-chart.tsx
Normal file
194
apps/web/src/components/governance/slo-violation-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user