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

@@ -215,8 +215,7 @@ export default function KnowledgeBasePage({
const totalCount = categories.reduce((sum, c) => sum + c.count, 0)
return (
<AppLayout locale={params.locale}>
const content = (
<div className="flex h-[calc(100vh-64px)]">
{/* 左側分類導航 */}
@@ -527,6 +526,12 @@ export default function KnowledgeBasePage({
</aside>
)}
</div>
)
return (
<AppLayout locale={params.locale}>
{content}
</AppLayout>
)
}

View File

@@ -2,24 +2,19 @@
/**
* 知識 (/knowledge) — Sprint 5 整合頁面
* 直接載入現有 knowledge-base 內容
* 零假數據: 原封不動使用現有頁面
* 建立時間: 2026-04-08 (台北時區)
* 直接渲染 knowledge-base 內容(不含 AppLayout
* @updated 2026-04-09 Claude Opus 4.6 — 移除 lazy import 避免 double AppLayout
*/
import { lazy, Suspense } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
const KnowledgeBaseContent = lazy(() => import('@/app/[locale]/knowledge-base/page'))
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
export default function KnowledgePage({ params }: { params: { locale: string } }) {
const t = useTranslations('common')
return (
<AppLayout locale={params.locale}>
<Suspense fallback={<div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>{t('loading')}</div>}>
<KnowledgeBaseContent params={params} />
</Suspense>
</AppLayout>
)
const router = useRouter()
useEffect(() => {
router.replace(`/${params.locale}/knowledge-base`)
}, [router, params.locale])
return null
}

View File

@@ -44,8 +44,8 @@ function AlertsAndApprovalsTab() {
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/api/v1/incidents`).then(r => r.json()).catch(() => ({ incidents: [] })),
fetch(`${API_BASE}/api/v1/approvals?status=pending`).then(r => r.json()).catch(() => []),
fetch(`${API_BASE}/api/v1/incidents`).then(r => r.ok ? r.json() : { incidents: [] }).catch(() => ({ incidents: [] })),
fetch(`${API_BASE}/api/v1/approvals/pending`).then(r => r.ok ? r.json() : []).catch(() => []),
]).then(([incData, apprData]) => {
setAlerts(incData.incidents ?? incData ?? [])
setApprovals(Array.isArray(apprData) ? apprData : apprData.approvals ?? [])

View File

@@ -3,28 +3,21 @@
/**
* 安全合規 (/security-compliance) — Sprint 5 整合頁面
* 整合: 安全掃描 + 合規報告
* 零假數據: 全部載入現有頁面內容
* 建立時間: 2026-04-08 (台北時區)
* @updated 2026-04-09 Claude Opus 4.6 — 改用 Panel 元件解決 double AppLayout
*/
import { lazy, Suspense } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
const SecurityContent = lazy(() => import('@/app/[locale]/security/page'))
const ComplianceContent = lazy(() => import('@/app/[locale]/compliance/page'))
function Loading() {
return <div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>...</div>
}
import { SecurityPanel } from '@/components/panels/SecurityPanel'
import { CompliancePanel } from '@/components/panels/CompliancePanel'
export default function SecurityCompliancePage({ params }: { params: { locale: string } }) {
const t = useTranslations('nav')
const tabs: TabConfig[] = [
{ id: 'security', label: t('security'), content: <Suspense fallback={<Loading />}><SecurityContent params={params} /></Suspense> },
{ id: 'compliance', label: t('compliance'), content: <Suspense fallback={<Loading />}><ComplianceContent params={params} /></Suspense> },
{ id: 'security', label: t('security'), content: <SecurityPanel /> },
{ id: 'compliance', label: t('compliance'), content: <CompliancePanel /> },
]
return (

View File

@@ -11,13 +11,23 @@
* @updated 2026-04-09 Claude Code — Sprint 5 React Flow 完整升級
*/
import { useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ServiceTopology } from '@/components/topology'
import { useDashboardStore } from '@/stores/dashboard.store'
export default function TopologyPage({ params }: { params: { locale: string } }) {
const t = useTranslations('topology')
// 確保 SSE 連接啟動,拓撲圖才有資料
useEffect(() => {
const apiBase = process.env.NEXT_PUBLIC_API_URL
if (apiBase) {
useDashboardStore.getState().connect(apiBase)
}
}, [])
return (
<AppLayout locale={params.locale}>
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 68px)' }}>

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'