feat(pages): 升級 5 個空殼頁面串接真實 API
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:
OG T
2026-04-03 00:11:27 +08:00
parent 0b83707697
commit cb0f92557d
5 changed files with 487 additions and 69 deletions

View File

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

View File

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

View File

@@ -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 (15)
</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>
)

View File

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

View File

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