fix(web): S11+S12 載入失敗修復 — Sprint 5R Phase 1A
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

- S11: Tab 2 approvals API path 修正 (?status=pending → /pending)
- S11: Tab 2 fetch 加 r.ok 檢查避免解析錯誤 JSON
- S12: 安全合規改用 SecurityPanel + CompliancePanel (解決 double AppLayout)
- S12: 知識庫改為 redirect 到 /knowledge-base (避免 lazy import 問題)
- S12: 拓撲圖加入 useDashboardStore.connect() 啟動 SSE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 15:43:06 +08:00
parent c180bdaaac
commit 289dac6bd1
8 changed files with 278 additions and 32 deletions

View File

@@ -0,0 +1,117 @@
'use client'
/**
* CompliancePanel — 合規狀態面板 (不含 AppLayout)
* Sprint 5R: 從 /compliance/page.tsx 抽取
* @updated 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 IncidentSummary {
total_incidents: number
resolved_rate: number
severity_distribution: Array<{ severity: string; count: number }>
}
interface AutoRepairStats {
approved_playbooks: number
high_quality_playbooks: number
total_executions: number
overall_success_rate: number
auto_repair_eligible: boolean
}
export function CompliancePanel() {
const t = useTranslations('compliance')
const [summary, setSummary] = useState<IncidentSummary | null>(null)
const [repairStats, setRepairStats] = useState<AutoRepairStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/api/v1/stats/incidents/summary?days=30`).then(r => r.ok ? r.json() : null).catch(() => null),
fetch(`${API_BASE}/api/v1/auto-repair/stats`).then(r => r.ok ? r.json() : null).catch(() => null),
])
.then(([s, r]) => {
if (s) setSummary(s)
if (r) setRepairStats(r)
})
.finally(() => setLoading(false))
}, [])
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
const sevColors: Record<string, string> = { P0: '#cc2200', P1: '#F59E0B', P2: '#4A90D9', P3: '#22C55E' }
return (
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0 }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0' }}>{t('subtitle')}</p>
</div>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontSize: 13 }}>{t('loading')}</div>
) : error ? (
<div style={{ ...cardStyle, textAlign: 'center', color: '#cc2200', fontSize: 13 }}>{t('error')}</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))', gap: 12, marginBottom: 16 }}>
{summary && <>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('totalIncidents')}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413' }}>{summary.total_incidents}</div>
<div style={{ fontSize: 11, color: '#87867f', marginTop: 4 }}>30 days</div>
</div>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('resolvedRate')}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: summary.resolved_rate >= 80 ? '#22C55E' : summary.resolved_rate >= 50 ? '#F59E0B' : '#cc2200' }}>
{summary.resolved_rate.toFixed(1)}%
</div>
</div>
</>}
{repairStats && <>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('approvedPlaybooks')}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413' }}>{repairStats.approved_playbooks}</div>
<div style={{ fontSize: 11, color: '#87867f', marginTop: 4 }}>{t('highQualityPlaybooks')}: {repairStats.high_quality_playbooks}</div>
</div>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('executionSuccessRate')}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: repairStats.overall_success_rate >= 0.8 ? '#22C55E' : '#F59E0B' }}>
{(repairStats.overall_success_rate * 100).toFixed(1)}%
</div>
</div>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('autoRepairEligible')}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: repairStats.auto_repair_eligible ? '#22C55E' : '#cc2200' }}>
{repairStats.auto_repair_eligible ? t('yes') : t('no')}
</div>
</div>
</>}
</div>
{summary && summary.severity_distribution.length > 0 && (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', color: '#141413' }}>
Severity Distribution
</div>
<div style={{ display: 'flex', gap: 0 }}>
{summary.severity_distribution.map(({ severity, count }) => (
<div key={severity} style={{ flex: 1, padding: '12px 14px', borderRight: '0.5px solid #f0ede4', textAlign: 'center' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: sevColors[severity] ?? '#87867f', marginBottom: 4 }}>{severity}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#141413' }}>{count}</div>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
/**
* SecurityPanel — 安全監控面板 (不含 AppLayout)
* Sprint 5R: 從 /security/page.tsx 抽取
* @updated 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 IssueStats {
total_issues: number
unresolved_issues: number
critical_count: number
error_count_24h: number
}
interface SentryIssue {
id: string
title: string
culprit: string | null
level: string
count: number
first_seen: string
last_seen: string
}
const LEVEL_COLOR: Record<string, string> = {
fatal: '#cc2200', error: '#cc2200', warning: '#F59E0B', info: '#4A90D9', debug: '#87867f',
}
export function SecurityPanel() {
const t = useTranslations('security')
const [stats, setStats] = useState<IssueStats | null>(null)
const [issues, setIssues] = useState<SentryIssue[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/api/v1/errors/stats`).then(r => r.ok ? r.json() : null).catch(() => null),
fetch(`${API_BASE}/api/v1/errors/issues?limit=20`).then(r => r.ok ? r.json() : { issues: [] }).catch(() => ({ issues: [] })),
])
.then(([statsData, listData]) => {
if (statsData) setStats(statsData)
setIssues(listData?.issues ?? [])
})
.finally(() => setLoading(false))
}, [])
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
return (
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0 }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0' }}>{t('subtitle')}</p>
</div>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontSize: 13 }}>{t('loading')}</div>
) : error ? (
<div style={{ ...cardStyle, textAlign: 'center', color: '#cc2200', fontSize: 13 }}>{t('error')}</div>
) : (
<>
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 12, marginBottom: 16 }}>
{[
{ label: t('totalIssues'), value: stats.total_issues, color: '#141413' },
{ label: t('criticalIssues'), value: stats.critical_count, color: stats.critical_count > 0 ? '#cc2200' : '#22C55E' },
{ label: 'Unresolved', value: stats.unresolved_issues, color: stats.unresolved_issues > 0 ? '#F59E0B' : '#22C55E' },
{ label: t('errorRate'), value: stats.error_count_24h != null ? `${stats.error_count_24h}/24h` : '—', color: '#141413' },
].map(card => (
<div key={card.label} style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{card.label}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: card.color }}>{card.value}</div>
</div>
))}
</div>
)}
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', color: '#141413' }}>
{t('recentIssues')} ({issues.length})
</div>
{issues.length === 0 ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontSize: 13 }}>{t('noData')}</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('issue'), 'Level', t('count'), 'Last Seen'].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{issues.map(issue => (
<tr key={issue.id} style={{ borderBottom: '0.5px solid #f0ede4' }}>
<td style={{ padding: '8px 14px', maxWidth: 300 }}>
<div style={{ fontWeight: 500, color: '#141413', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{issue.title}</div>
{issue.culprit && <div style={{ fontSize: 11, color: '#87867f', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{issue.culprit}</div>}
</td>
<td style={{ padding: '8px 14px' }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: LEVEL_COLOR[issue.level] ?? '#87867f' }}>{issue.level}</span>
</td>
<td style={{ padding: '8px 14px', color: '#87867f' }}>{issue.count}</td>
<td style={{ padding: '8px 14px', color: '#87867f', fontSize: 11 }}>
{new Date(issue.last_seen).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</div>
)
}

View File

@@ -21,3 +21,5 @@ export { TicketsPanel } from './TicketsPanel'
export { CostPanel } from './CostPanel'
export { ActionLogsPanel } from './ActionLogsPanel'
export { BillingPanel } from './BillingPanel'
export { SecurityPanel } from './SecurityPanel'
export { CompliancePanel } from './CompliancePanel'