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

@@ -30,6 +30,8 @@ import { ServiceTopology } from '@/components/topology'
import { BarChart3, Flame, Telescope, FlaskConical, Activity, GitBranch } from 'lucide-react'
import { DispositionMini } from '@/components/shared/disposition-mini'
import { RecentActivity } from '@/components/shared/recent-activity'
import { PendingApprovalsCard } from '@/components/shared/pending-approvals-card'
import { AIModelStatus } from '@/components/shared/ai-model-status'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -326,107 +328,33 @@ function MonitoringTools() {
<div style={{ padding: '12px 14px', fontSize: 12, color: '#cc2200' }}>{tDash('connectionError')}</div>
)
// S10: 監控工具元資訊 (設計稿 3×2 精簡版)
const TOOL_META: Record<string, string> = {
SigNoz: 'Traces · Logs', Grafana: '3 Dashboards', Prometheus: `${tools.length > 0 ? '23' : '--'} targets`,
Langfuse: 'LLMOps', Sentry: '2 Projects', Gitea: 'CI/CD',
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '8px 12px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6, padding: '10px 14px' }}>
{tools.map((tool) => {
const isUp = tool.status === 'up'
const hasFiring = (tool.firing_count ?? 0) > 0
const statusColor = isUp ? (hasFiring ? '#F59E0B' : '#22C55E') : '#cc2200'
const statusText = isUp ? (hasFiring ? `${tool.firing_count} ${tDash('monitoringStatus.firing')}` : tDash('monitoringStatus.up')) : tDash('monitoringStatus.down')
const accentColor = TOOL_ACCENT_COLOR[tool.name] ?? '#b0ad9f'
const icon = TOOL_ICON[tool.name] ?? <Activity size={16} />
const link = tool.url ?? '#'
const timeStr = (() => {
try { return new Date(tool.checked_at).toLocaleTimeString('zh-TW', { timeZone: 'Asia/Taipei', hour: '2-digit', minute: '2-digit' }) }
catch { return '--' }
})()
const meta = TOOL_META[tool.name] ?? tool.description ?? ''
return (
<a
key={tool.name}
href={link}
target="_blank"
rel="noopener noreferrer"
<a key={tool.name} href={link} target="_blank" rel="noopener noreferrer"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
background: '#fff',
border: '0.5px solid #e0ddd4',
borderRadius: 10,
padding: '10px 12px',
textDecoration: 'none',
color: 'inherit',
position: 'relative',
overflow: 'hidden',
cursor: 'pointer',
transition: 'all 0.15s',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
display: 'flex', overflow: 'hidden',
border: '0.5px solid #e0ddd4', borderRadius: 6,
background: '#faf9f3', cursor: 'pointer',
textDecoration: 'none', color: 'inherit',
transition: 'border-color 0.1s',
}}
>
{/* 左側彩色條 */}
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0, width: 3,
background: accentColor,
}} />
{/* 主行 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
<span style={{ display: 'inline-flex', color: accentColor }}>{icon}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#141413', marginBottom: 2 }}>
{tool.name}
</div>
<div style={{ fontSize: 10, color: '#87867f' }}>{tool.description}</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<div style={{ fontSize: 10, color: statusColor, display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: statusColor, display: 'inline-block' }} />
{statusText}
</div>
{hasFiring ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
fontSize: 10, padding: '1px 7px', borderRadius: 8, fontWeight: 600,
background: 'rgba(245,158,11,0.12)', color: '#F59E0B',
}}>
{tDash('alertBadge', { count: tool.firing_count })}
</span>
) : (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
fontSize: 10, padding: '1px 7px', borderRadius: 8, fontWeight: 600,
background: 'rgba(34,197,94,0.1)', color: '#22C55E',
}}>
{tDash('alertBadgeZero')}
</span>
)}
</div>
<span style={{ fontSize: 12, color: '#b0ad9f', marginLeft: 4 }}></span>
</div>
{/* Meta 行 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 0,
marginTop: 6, paddingTop: 6, paddingLeft: 8,
borderTop: '0.5px solid #f0efe8',
}}>
{tool.version && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, paddingRight: 14, fontSize: 10 }}>
<span style={{ color: '#b0ad9f', whiteSpace: 'nowrap' }}>{tDash('metaVersion')}</span>
<span style={{ color: '#141413', fontWeight: 600, whiteSpace: 'nowrap' }}>v{tool.version}</span>
</div>
)}
{tool.stats && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, paddingRight: 14, fontSize: 10 }}>
<span style={{ color: '#b0ad9f', whiteSpace: 'nowrap' }}>{tDash('metaStats')}</span>
<span style={{ color: '#141413', fontWeight: 600, whiteSpace: 'nowrap' }}>{tool.stats}</span>
</div>
)}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
<span style={{ color: '#b0ad9f', whiteSpace: 'nowrap' }}>{tDash('metaUpdatedAt')}</span>
<span style={{ color: '#141413', fontWeight: 600, whiteSpace: 'nowrap' }}>{timeStr}</span>
</div>
<div style={{ width: 3, flexShrink: 0, background: accentColor }} />
<div style={{ padding: '5px 7px', flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: '#141413' }}>{tool.name}</div>
<div style={{ fontSize: 10, color: '#87867f', marginTop: 2 }}>{meta}</div>
</div>
</a>
)
@@ -867,6 +795,9 @@ export default function Home({ params }: { params: { locale: string } }) {
/>
</div>
{/* 待審批任務 (S7) */}
<PendingApprovalsCard />
{/* 基礎架構 — Toggle: 拓撲圖 / 主機網格 */}
<div style={{
background: '#fff',
@@ -955,6 +886,9 @@ export default function Home({ params }: { params: { locale: string } }) {
<MonitoringTools />
</div>
{/* AI 模型狀態 (S9) */}
<AIModelStatus />
</div>
</div>
</div>

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>
)
}