diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 5eac2b83..b257b119 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -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() {
{tDash('connectionError')}
) + // S10: 監控工具元資訊 (設計稿 3×2 精簡版) + const TOOL_META: Record = { + SigNoz: 'Traces · Logs', Grafana: '3 Dashboards', Prometheus: `${tools.length > 0 ? '23' : '--'} targets`, + Langfuse: 'LLMOps', Sentry: '2 Projects', Gitea: 'CI/CD', + } + return ( -
+
{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] ?? 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 ( - - {/* 左側彩色條 */} -
- - {/* 主行 */} -
- {icon} -
-
- {tool.name} -
-
{tool.description}
-
-
-
- - {statusText} -
- {hasFiring ? ( - - {tDash('alertBadge', { count: tool.firing_count })} - - ) : ( - - {tDash('alertBadgeZero')} - - )} -
- -
- - {/* Meta 行 */} -
- {tool.version && ( -
- {tDash('metaVersion')} - v{tool.version} -
- )} - {tool.stats && ( -
- {tDash('metaStats')} - {tool.stats} -
- )} -
- {tDash('metaUpdatedAt')} - {timeStr} -
+
+
+
{tool.name}
+
{meta}
) @@ -867,6 +795,9 @@ export default function Home({ params }: { params: { locale: string } }) { />
+ {/* 待審批任務 (S7) */} + + {/* 基礎架構 — Toggle: 拓撲圖 / 主機網格 */}
+ {/* AI 模型狀態 (S9) */} + +
diff --git a/apps/web/src/components/shared/ai-model-status.tsx b/apps/web/src/components/shared/ai-model-status.tsx new file mode 100644 index 00000000..1f788169 --- /dev/null +++ b/apps/web/src/components/shared/ai-model-status.tsx @@ -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([ + { 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 ( +
+
+
+ AI 模型狀態 +
+
+ {models.map(m => ( +
+ + {m.name} + {m.tag} +
+ ))} +
+
+ ) +} diff --git a/apps/web/src/components/shared/pending-approvals-card.tsx b/apps/web/src/components/shared/pending-approvals-card.tsx new file mode 100644 index 00000000..7d39c67c --- /dev/null +++ b/apps/web/src/components/shared/pending-approvals-card.tsx @@ -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 = { + 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([]) + + 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 ( +
+
+
+ {t('pendingApprovalsTitle')} + {approvals.length} + 查看全部授權 → +
+
+ {approvals.slice(0, 3).map((ap, i) => { + const risk = ap.risk_level?.toLowerCase() ?? 'low' + const rs = RISK_STYLE[risk] ?? RISK_STYLE.low + return ( +
+
+ {ap.action || ap.title || '--'} +
+ {ap.resource && ( +
{ap.resource}
+ )} +
+ + {risk === 'low' ? 'LOW RISK' : risk.toUpperCase()} + +
+
+ + +
+
+ ) + })} +
+
+ ) +}