feat(pages): 升級 5 個空殼頁面串接真實 API
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 6m45s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 6m45s
- billing: /api/v1/audit-logs/stats (by operation/namespace) - compliance: /api/v1/stats/incidents/summary + auto-repair/stats - cost: /api/v1/stats/ai-performance (提案執行率/成功率) - security: /api/v1/errors/stats + /errors/issues (Sentry BFF) - users: /api/v1/audit-logs/stats + /audit-logs (操作稽核) 全部真實數據,無假頁面、無 mock data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,42 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 帳單 Page
|
||||
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
|
||||
* @updated 2026-04-02 ogt - 升級為真實空頁(無 billing API)
|
||||
* 使用量 Page — 系統操作使用量統計
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/audit-logs/stats 真實數據
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
|
||||
interface AuditStats {
|
||||
total_executions: number
|
||||
success_count: number
|
||||
failure_count: number
|
||||
success_rate: number
|
||||
avg_duration_ms: number | null
|
||||
last_24h_count: number
|
||||
by_operation_type: Record<string, number>
|
||||
by_namespace: Record<string, number>
|
||||
}
|
||||
|
||||
export default function BillingPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('billing')
|
||||
const [stats, setStats] = useState<AuditStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/audit-logs/stats`)
|
||||
.then(r => r.json())
|
||||
.then((d: AuditStats) => { setStats(d); setLoading(false) })
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
@@ -20,22 +46,67 @@ export default function BillingPage({ params }: { params: { locale: string } })
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{[t('currentMonth'), t('totalUsage')].map(label => (
|
||||
<div key={label} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '20px 24px', minWidth: 160 }}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 6 }}>{label}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>--</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
|
||||
) : stats ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: t('totalUsage'), value: stats.total_executions },
|
||||
{ label: t('last24h'), value: stats.last_24h_count },
|
||||
{ label: t('successRate'), value: `${(stats.success_rate * 100).toFixed(1)}%` },
|
||||
{ label: t('avgDuration'), value: stats.avg_duration_ms ? `${stats.avg_duration_ms.toFixed(0)}ms` : '—' },
|
||||
].map(card => (
|
||||
<div key={card.label} style={cardStyle}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{card.label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16, background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
|
||||
{t('title')}
|
||||
</div>
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
|
||||
</div>
|
||||
{/* By Operation Type */}
|
||||
{Object.keys(stats.by_operation_type).length > 0 && (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
By Operation Type
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
<tbody>
|
||||
{Object.entries(stats.by_operation_type).sort(([, a], [, b]) => b - a).map(([op, count]) => (
|
||||
<tr key={op} style={{ borderBottom: '0.5px solid #f0ede4' }}>
|
||||
<td style={{ padding: '7px 14px', fontWeight: 500, color: '#141413' }}>{op}</td>
|
||||
<td style={{ padding: '7px 14px', color: '#87867f', textAlign: 'right' }}>{count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* By Namespace */}
|
||||
{Object.keys(stats.by_namespace).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', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
By Namespace
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
<tbody>
|
||||
{Object.entries(stats.by_namespace).sort(([, a], [, b]) => b - a).map(([ns, count]) => (
|
||||
<tr key={ns} style={{ borderBottom: '0.5px solid #f0ede4' }}>
|
||||
<td style={{ padding: '7px 14px', fontWeight: 500, color: '#141413' }}>{ns}</td>
|
||||
<td style={{ padding: '7px 14px', color: '#87867f', textAlign: 'right' }}>{count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 合規 Page
|
||||
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
|
||||
* @updated 2026-04-02 ogt - 升級為真實空頁(無 compliance API)
|
||||
* 合規 Page — 系統治理合規狀態
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/stats 及 auto-repair 真實數據
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
|
||||
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 default function CompliancePage({ params }: { params: { locale: string } }) {
|
||||
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.json()),
|
||||
fetch(`${API_BASE}/api/v1/auto-repair/stats`).then(r => r.json()),
|
||||
])
|
||||
.then(([s, r]: [IncidentSummary, AutoRepairStats]) => {
|
||||
setSummary(s)
|
||||
setRepairStats(r)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
@@ -20,13 +56,68 @@ export default function CompliancePage({ params }: { params: { locale: string }
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
|
||||
{t('title')}
|
||||
</div>
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', 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', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('totalIncidents')}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{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', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('resolvedRate')}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: summary.resolved_rate >= 0.8 ? '#22C55E' : summary.resolved_rate >= 0.5 ? '#F59E0B' : '#cc2200', fontFamily: 'var(--font-body), monospace' }}>
|
||||
{(summary.resolved_rate * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
{repairStats && <>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('approvedPlaybooks')}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{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', fontFamily: 'var(--font-body), monospace', 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', fontFamily: 'var(--font-body), monospace' }}>
|
||||
{(repairStats.overall_success_rate * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('autoRepairEligible')}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: repairStats.auto_repair_eligible ? '#22C55E' : '#cc2200', fontFamily: 'var(--font-body), monospace' }}>
|
||||
{repairStats.auto_repair_eligible ? t('yes') : t('no')}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* Severity Distribution */}
|
||||
{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', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
Severity Distribution
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
{summary.severity_distribution.map(({ severity, count }) => {
|
||||
const sevColors: Record<string, string> = { P0: '#cc2200', P1: '#F59E0B', P2: '#4A90D9', P3: '#22C55E' }
|
||||
return (
|
||||
<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', fontFamily: 'var(--font-body), monospace', marginBottom: 4 }}>{severity}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{count}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 成本分析 Page
|
||||
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
|
||||
* @updated 2026-04-02 ogt - 升級為真實空頁(無 cost API)
|
||||
* 成本分析 Page — AI 執行效能統計
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/stats/ai-performance 真實數據
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
|
||||
interface AIPerformance {
|
||||
total_proposals: number
|
||||
executed_count: number
|
||||
execution_rate: number
|
||||
success_count: number
|
||||
success_rate: number
|
||||
avg_effectiveness: number | null
|
||||
effectiveness_distribution: Record<string, number>
|
||||
}
|
||||
|
||||
export default function CostPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('cost')
|
||||
const [data, setData] = useState<AIPerformance | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/stats/ai-performance?days=30`)
|
||||
.then(r => r.json())
|
||||
.then((d: AIPerformance) => { setData(d); setLoading(false) })
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
@@ -20,13 +45,50 @@ export default function CostPage({ params }: { params: { locale: string } }) {
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
|
||||
{t('title')}
|
||||
</div>
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
|
||||
) : data ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: t('totalProposals'), value: data.total_proposals },
|
||||
{ label: t('executionRate'), value: `${(data.execution_rate).toFixed(1)}%` },
|
||||
{ label: t('successRate'), value: `${(data.success_rate).toFixed(1)}%` },
|
||||
{ label: t('avgEffectiveness'), value: data.avg_effectiveness ? data.avg_effectiveness.toFixed(2) : '—' },
|
||||
].map(card => (
|
||||
<div key={card.label} style={cardStyle}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{card.label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Effectiveness Distribution */}
|
||||
{data.effectiveness_distribution && Object.keys(data.effectiveness_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', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
Effectiveness Distribution (1–5)
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
{[1, 2, 3, 4, 5].map(score => {
|
||||
const count = data.effectiveness_distribution[String(score)] ?? 0
|
||||
const barColors = ['#cc2200', '#F59E0B', '#87867f', '#4A90D9', '#22C55E']
|
||||
return (
|
||||
<div key={score} style={{ flex: 1, padding: '12px 14px', borderRight: '0.5px solid #f0ede4', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: barColors[score - 1], fontFamily: 'var(--font-body), monospace', marginBottom: 4 }}>★{score}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{count}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,68 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 安全 Page
|
||||
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
|
||||
* @updated 2026-04-02 ogt - 升級為真實空頁(無 security API)
|
||||
* 安全 Page — 錯誤與安全事件監控 (Sentry BFF)
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/errors 真實數據
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
interface IssueListResponse {
|
||||
issues: SentryIssue[]
|
||||
total: number
|
||||
}
|
||||
|
||||
const LEVEL_COLOR: Record<string, string> = {
|
||||
fatal: '#cc2200',
|
||||
error: '#cc2200',
|
||||
warning: '#F59E0B',
|
||||
info: '#4A90D9',
|
||||
debug: '#87867f',
|
||||
}
|
||||
|
||||
export default function SecurityPage({ params }: { params: { locale: string } }) {
|
||||
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.json()),
|
||||
fetch(`${API_BASE}/api/v1/errors/issues?limit=20`).then(r => r.json()),
|
||||
])
|
||||
.then(([statsData, listData]: [IssueStats, IssueListResponse]) => {
|
||||
setStats(statsData)
|
||||
setIssues(listData.issues ?? [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
@@ -20,13 +72,65 @@ export default function SecurityPage({ params }: { params: { locale: string } })
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
|
||||
{t('title')}
|
||||
</div>
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', 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 ? String(stats.error_count_24h) + `/24h` : '—', color: '#141413' },
|
||||
].map(card => (
|
||||
<div key={card.label} style={cardStyle}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{card.label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: card.color, fontFamily: 'var(--font-body), monospace' }}>{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', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
{t('recentIssues')} ({issues.length})
|
||||
</div>
|
||||
{issues.length === 0 ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', 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>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,64 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 使用者管理 Page
|
||||
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
|
||||
* @updated 2026-04-02 ogt - 升級為真實空列表頁(無 users API)
|
||||
* 操作稽核 Page — K8s 操作執行紀錄
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/audit-logs 真實數據
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
|
||||
interface AuditStats {
|
||||
total_executions: number
|
||||
success_count: number
|
||||
failure_count: number
|
||||
success_rate: number
|
||||
avg_duration_ms: number | null
|
||||
last_24h_count: number
|
||||
by_operation_type: Record<string, number>
|
||||
by_namespace: Record<string, number>
|
||||
}
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
operation_type: string
|
||||
namespace: string | null
|
||||
success: boolean
|
||||
duration_ms: number | null
|
||||
executed_at: string
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
interface AuditLogListResponse {
|
||||
logs: AuditLog[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export default function UsersPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('users')
|
||||
const [stats, setStats] = useState<AuditStats | null>(null)
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/api/v1/audit-logs/stats`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/api/v1/audit-logs?page=1&page_size=20`).then(r => r.json()),
|
||||
])
|
||||
.then(([statsData, logsData]: [AuditStats, AuditLogListResponse]) => {
|
||||
setStats(statsData)
|
||||
setLogs(logsData.logs ?? [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
@@ -20,26 +68,68 @@ export default function UsersPage({ params }: { params: { locale: string } }) {
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
|
||||
{t('title')}
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#faf9f3' }}>
|
||||
{[t('name'), t('role'), t('status')].map(col => (
|
||||
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: t('totalExecutions'), value: stats.total_executions },
|
||||
{ label: t('successCount'), value: stats.success_count },
|
||||
{ label: t('failureCount'), value: stats.failure_count },
|
||||
{ label: t('successRate'), value: `${(stats.success_rate * 100).toFixed(1)}%` },
|
||||
{ label: t('last24h'), value: stats.last_24h_count },
|
||||
{ label: t('avgDuration'), value: stats.avg_duration_ms ? `${stats.avg_duration_ms.toFixed(0)}ms` : '—' },
|
||||
].map((card) => (
|
||||
<div key={card.label} style={cardStyle}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{card.label}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={3} style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noUsers')}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Ops Table */}
|
||||
<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', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
|
||||
{t('recentOps')} ({logs.length})
|
||||
</div>
|
||||
{logs.length === 0 ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noUsers')}</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#faf9f3' }}>
|
||||
{[t('operation'), t('namespace'), t('result'), t('time')].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>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} style={{ borderBottom: '0.5px solid #f0ede4' }}>
|
||||
<td style={{ padding: '7px 14px', fontWeight: 500, color: '#141413' }}>{log.operation_type}</td>
|
||||
<td style={{ padding: '7px 14px', color: '#87867f' }}>{log.namespace ?? '—'}</td>
|
||||
<td style={{ padding: '7px 14px' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: log.success ? '#22C55E' : '#cc2200' }}>
|
||||
{log.success ? '✓ OK' : '✗ FAIL'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '7px 14px', color: '#87867f', fontSize: 11 }}>
|
||||
{new Date(log.executed_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user