feat(web): S7+S9+S10 待審批+AI模型+監控工具3×2 — Sprint 5R
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 12m15s

- S7: PendingApprovalsCard 含風險標籤 + 批准/拒絕按鈕
- S9: AIModelStatus 2×2 (OpenClaw/Ollama/Gemini/NVIDIA)
- S10: MonitoringTools 改 3×2 網格 (名稱+元資訊+左側色條)
- 右欄順序: OpenClaw → 待審批 → 基礎架構 → 監控工具 → AI模型

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 16:10:28 +08:00
parent a0f3a7d532
commit 895784e646
3 changed files with 193 additions and 92 deletions

View File

@@ -0,0 +1,69 @@
'use client'
/**
* AIModelStatus — AI 模型狀態 2×2 網格
* Sprint 5R S9: 設計稿 L531-545
* @created 2026-04-09 Claude Opus 4.6 Asia/Taipei
*/
import { useState, useEffect } from 'react'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface ModelInfo {
name: string
tag: string
healthy: boolean
}
export function AIModelStatus() {
const [models, setModels] = useState<ModelInfo[]>([
{ name: 'OpenClaw Nemo', tag: 'local', healthy: false },
{ name: 'Ollama gemma3', tag: 'local', healthy: false },
{ name: 'Gemini Pro', tag: 'cloud', healthy: false },
{ name: 'NVIDIA NIM', tag: 'cloud', healthy: false },
])
useEffect(() => {
fetch(`${API_BASE}/api/v1/health`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d?.components) return
setModels(prev => prev.map(m => {
if (m.name.includes('OpenClaw') && d.components.openclaw) return { ...m, healthy: d.components.openclaw.status === 'up' }
if (m.name.includes('Ollama') && d.components.ollama) return { ...m, healthy: d.components.ollama.status === 'up' }
if (m.name.includes('Gemini')) return { ...m, healthy: true } // cloud assumed up
if (m.name.includes('NVIDIA')) return { ...m, healthy: true }
return m
}))
})
.catch(() => {})
}, [])
return (
<div style={{
background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10,
overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.04)', flexShrink: 0,
}}>
<div style={{
padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4',
display: 'flex', alignItems: 'center', gap: 8, background: '#faf9f3',
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413' }}>AI </span>
</div>
<div style={{ padding: 14, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{models.map(m => (
<div key={m.name} style={{
border: '0.5px solid #e0ddd4', borderRadius: 6, padding: '6px 8px',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: m.healthy ? '#22C55E' : '#cc2200', flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 500, color: '#141413' }}>{m.name}</span>
<span style={{ fontSize: 10, color: '#87867f', marginLeft: 'auto' }}>{m.tag}</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
/**
* PendingApprovalsCard — 待審批任務 (右欄卡片)
* Sprint 5R S7: 設計稿 L481-503
* @created 2026-04-09 Claude Opus 4.6 Asia/Taipei
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface Approval {
id: string
action: string
title?: string
resource?: string
risk_level?: string
severity?: string
}
const RISK_STYLE: Record<string, { bg: string; color: string }> = {
low: { bg: 'rgba(34,197,94,0.08)', color: '#22C55E' },
medium: { bg: 'rgba(245,158,11,0.08)', color: '#F59E0B' },
high: { bg: 'rgba(204,34,0,0.08)', color: '#cc2200' },
critical: { bg: 'rgba(204,34,0,0.12)', color: '#cc2200' },
}
export function PendingApprovalsCard() {
const t = useTranslations('dashboard')
const [approvals, setApprovals] = useState<Approval[]>([])
useEffect(() => {
fetch(`${API_BASE}/api/v1/approvals/pending`)
.then(r => r.ok ? r.json() : [])
.then(d => setApprovals(Array.isArray(d) ? d : d.approvals ?? []))
.catch(() => {})
}, [])
if (approvals.length === 0) return null
return (
<div style={{
background: '#fff', border: '0.5px solid rgba(249,115,22,0.3)', borderRadius: 10,
overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.04)', flexShrink: 0,
}}>
<div style={{
padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4',
display: 'flex', alignItems: 'center', gap: 8, background: 'rgba(249,115,22,0.04)',
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#F59E0B' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413' }}>{t('pendingApprovalsTitle')}</span>
<span style={{ fontSize: 11, background: 'rgba(249,115,22,0.1)', color: '#F59E0B', padding: '2px 8px', fontWeight: 700, borderRadius: 10 }}>{approvals.length}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}> </span>
</div>
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
{approvals.slice(0, 3).map((ap, i) => {
const risk = ap.risk_level?.toLowerCase() ?? 'low'
const rs = RISK_STYLE[risk] ?? RISK_STYLE.low
return (
<div key={ap.id || i} style={{ background: '#faf9f3', border: '0.5px solid #e0ddd4', borderRadius: 6, padding: '8px 10px' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: risk === 'critical' || risk === 'high' ? '#cc2200' : '#F59E0B' }}>
{ap.action || ap.title || '--'}
</div>
{ap.resource && (
<div style={{ fontSize: 11, color: '#555550', marginTop: 2, fontFamily: "'JetBrains Mono', monospace" }}>{ap.resource}</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 3, fontWeight: 600, background: rs.bg, color: rs.color, textTransform: 'uppercase' }}>
{risk === 'low' ? 'LOW RISK' : risk.toUpperCase()}
</span>
</div>
<div style={{ display: 'flex', gap: 4, marginTop: 5 }}>
<button
onClick={() => {
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/sign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ signer: 'web-ui' }) })
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
.catch(() => {})
}}
style={{ flex: 1, padding: 6, border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer', background: '#22C55E', color: '#fff' }}
>{t('approve')}</button>
<button
onClick={() => {
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'rejected-from-web' }) })
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
.catch(() => {})
}}
style={{ flex: 1, padding: 6, border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: '#fff', color: '#87867f' }}
>{t('reject')}</button>
</div>
</div>
)
})}
</div>
</div>
)
}