diff --git a/apps/web/src/app/[locale]/billing/page.tsx b/apps/web/src/app/[locale]/billing/page.tsx index 7e591ce1..5a5bac36 100644 --- a/apps/web/src/app/[locale]/billing/page.tsx +++ b/apps/web/src/app/[locale]/billing/page.tsx @@ -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 + by_namespace: Record +} + export default function BillingPage({ params }: { params: { locale: string } }) { const t = useTranslations('billing') + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( @@ -20,22 +46,67 @@ export default function BillingPage({ params }: { params: { locale: string } })

{t('subtitle')}

-
- {[t('currentMonth'), t('totalUsage')].map(label => ( -
-
{label}
-
--
+ {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : stats ? ( + <> +
+ {[ + { 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 => ( +
+
{card.label}
+
{card.value}
+
+ ))}
- ))} -
-
-
- - {t('title')} -
-
{t('noData')}
-
+ {/* By Operation Type */} + {Object.keys(stats.by_operation_type).length > 0 && ( +
+
+ By Operation Type +
+ + + {Object.entries(stats.by_operation_type).sort(([, a], [, b]) => b - a).map(([op, count]) => ( + + + + + ))} + +
{op}{count}
+
+ )} + + {/* By Namespace */} + {Object.keys(stats.by_namespace).length > 0 && ( +
+
+ By Namespace +
+ + + {Object.entries(stats.by_namespace).sort(([, a], [, b]) => b - a).map(([ns, count]) => ( + + + + + ))} + +
{ns}{count}
+
+ )} + + ) : ( +
{t('noData')}
+ )}
) diff --git a/apps/web/src/app/[locale]/compliance/page.tsx b/apps/web/src/app/[locale]/compliance/page.tsx index 5bb080a3..3b44ca1f 100644 --- a/apps/web/src/app/[locale]/compliance/page.tsx +++ b/apps/web/src/app/[locale]/compliance/page.tsx @@ -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(null) + const [repairStats, setRepairStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( @@ -20,13 +56,68 @@ export default function CompliancePage({ params }: { params: { locale: string }

{t('subtitle')}

-
-
- - {t('title')} -
-
{t('noData')}
-
+ {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : ( + <> +
+ {summary && <> +
+
{t('totalIncidents')}
+
{summary.total_incidents}
+
30 days
+
+
+
{t('resolvedRate')}
+
= 0.8 ? '#22C55E' : summary.resolved_rate >= 0.5 ? '#F59E0B' : '#cc2200', fontFamily: 'var(--font-body), monospace' }}> + {(summary.resolved_rate * 100).toFixed(1)}% +
+
+ } + {repairStats && <> +
+
{t('approvedPlaybooks')}
+
{repairStats.approved_playbooks}
+
{t('highQualityPlaybooks')}: {repairStats.high_quality_playbooks}
+
+
+
{t('executionSuccessRate')}
+
= 0.8 ? '#22C55E' : '#F59E0B', fontFamily: 'var(--font-body), monospace' }}> + {(repairStats.overall_success_rate * 100).toFixed(1)}% +
+
+
+
{t('autoRepairEligible')}
+
+ {repairStats.auto_repair_eligible ? t('yes') : t('no')} +
+
+ } +
+ + {/* Severity Distribution */} + {summary && summary.severity_distribution.length > 0 && ( +
+
+ Severity Distribution +
+
+ {summary.severity_distribution.map(({ severity, count }) => { + const sevColors: Record = { P0: '#cc2200', P1: '#F59E0B', P2: '#4A90D9', P3: '#22C55E' } + return ( +
+
{severity}
+
{count}
+
+ ) + })} +
+
+ )} + + )}
) diff --git a/apps/web/src/app/[locale]/cost/page.tsx b/apps/web/src/app/[locale]/cost/page.tsx index 4961d31d..5675987a 100644 --- a/apps/web/src/app/[locale]/cost/page.tsx +++ b/apps/web/src/app/[locale]/cost/page.tsx @@ -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 +} + export default function CostPage({ params }: { params: { locale: string } }) { const t = useTranslations('cost') + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( @@ -20,13 +45,50 @@ export default function CostPage({ params }: { params: { locale: string } }) {

{t('subtitle')}

-
-
- - {t('title')} -
-
{t('noData')}
-
+ {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : data ? ( + <> +
+ {[ + { 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 => ( +
+
{card.label}
+
{card.value}
+
+ ))} +
+ + {/* Effectiveness Distribution */} + {data.effectiveness_distribution && Object.keys(data.effectiveness_distribution).length > 0 && ( +
+
+ Effectiveness Distribution (1–5) +
+
+ {[1, 2, 3, 4, 5].map(score => { + const count = data.effectiveness_distribution[String(score)] ?? 0 + const barColors = ['#cc2200', '#F59E0B', '#87867f', '#4A90D9', '#22C55E'] + return ( +
+
★{score}
+
{count}
+
+ ) + })} +
+
+ )} + + ) : ( +
{t('noData')}
+ )}
) diff --git a/apps/web/src/app/[locale]/security/page.tsx b/apps/web/src/app/[locale]/security/page.tsx index 8c10979c..2dc99872 100644 --- a/apps/web/src/app/[locale]/security/page.tsx +++ b/apps/web/src/app/[locale]/security/page.tsx @@ -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 = { + 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(null) + const [issues, setIssues] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( @@ -20,13 +72,65 @@ export default function SecurityPage({ params }: { params: { locale: string } })

{t('subtitle')}

-
-
- - {t('title')} -
-
{t('noData')}
-
+ {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : ( + <> + {stats && ( +
+ {[ + { 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 => ( +
+
{card.label}
+
{card.value}
+
+ ))} +
+ )} + +
+
+ {t('recentIssues')} ({issues.length}) +
+ {issues.length === 0 ? ( +
{t('noData')}
+ ) : ( + + + + {[t('issue'), 'Level', t('count'), 'Last Seen'].map(col => ( + + ))} + + + + {issues.map(issue => ( + + + + + + + ))} + +
{col}
+
{issue.title}
+ {issue.culprit &&
{issue.culprit}
} +
+ {issue.level} + {issue.count} + {new Date(issue.last_seen).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+ )} +
+ + )}
) diff --git a/apps/web/src/app/[locale]/users/page.tsx b/apps/web/src/app/[locale]/users/page.tsx index 19b53a85..85899553 100644 --- a/apps/web/src/app/[locale]/users/page.tsx +++ b/apps/web/src/app/[locale]/users/page.tsx @@ -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 + by_namespace: Record +} + +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(null) + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( @@ -20,26 +68,68 @@ export default function UsersPage({ params }: { params: { locale: string } }) {

{t('subtitle')}

-
-
- - {t('title')} -
- - - - {[t('name'), t('role'), t('status')].map(col => ( - + {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : ( + <> + {/* Stats Cards */} + {stats && ( +
+ {[ + { 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) => ( +
+
{card.label}
+
{card.value}
+
))} -
- - - - - - -
{col}
{t('noUsers')}
-
+ + )} + + {/* Recent Ops Table */} +
+
+ {t('recentOps')} ({logs.length}) +
+ {logs.length === 0 ? ( +
{t('noUsers')}
+ ) : ( + + + + {[t('operation'), t('namespace'), t('result'), t('time')].map(col => ( + + ))} + + + + {logs.map((log) => ( + + + + + + + ))} + +
{col}
{log.operation_type}{log.namespace ?? '—'} + + {log.success ? '✓ OK' : '✗ FAIL'} + + + {new Date(log.executed_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+ )} +
+ + )}
)