feat(web): S7+S9+S10 待審批+AI模型+監控工具3×2 — Sprint 5R
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 12m15s
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:
69
apps/web/src/components/shared/ai-model-status.tsx
Normal file
69
apps/web/src/components/shared/ai-model-status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
apps/web/src/components/shared/pending-approvals-card.tsx
Normal file
98
apps/web/src/components/shared/pending-approvals-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user