refactor(web): Panel 抽取全面完成 — 三個整合頁面解決雙重 AppLayout
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
/observability: AppsPanel + ServicesPanel (共 5/5 Tab 完成) /automation: AutoRepairPanel + NeuralCommandPanel + DriftPanel (3/3) /operations: DeploymentsPanel + TicketsPanel + CostPanel + ActionLogsPanel + BillingPanel (5/5) 原始頁面全部精簡為 AppLayout + Panel,零雙重 Layout。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,551 +1,17 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Action Log Page - K8s 操作稽核日誌
|
||||
* ==================================
|
||||
* Phase 4: 行動日誌介面
|
||||
*
|
||||
* Features:
|
||||
* - 真實 API 數據 (GET /api/v1/audit-logs)
|
||||
* - 分頁顯示
|
||||
* - 統計概覽
|
||||
* - 操作類型、狀態篩選
|
||||
* - 執行時間、耗時、簽核者資訊
|
||||
*
|
||||
* i18n: 100% next-intl,零硬編碼
|
||||
*
|
||||
* 版本: v1.1
|
||||
* 變更: 2026-03-31 (台北時區) - Claude Code
|
||||
* - #19 P2: 新增 AbortController 防止 unmount 時記憶體洩漏
|
||||
* Action Log Page — 路由入口
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + ActionLogsPanel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { DataPincerPanel } from '@/components/cyber'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { ActionLogsPanel } from '@/components/panels/ActionLogsPanel'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
approval_id: string
|
||||
operation_type: string
|
||||
target_resource: string
|
||||
namespace: string
|
||||
success: boolean
|
||||
error_message: string | null
|
||||
k8s_response: Record<string, unknown> | null
|
||||
executed_by: string
|
||||
execution_duration_ms: number | null
|
||||
dry_run_passed: boolean
|
||||
dry_run_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface AuditLogListResponse {
|
||||
count: number
|
||||
logs: AuditLog[]
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
interface AuditStats {
|
||||
total_executions: number
|
||||
success_count: number
|
||||
failure_count: number
|
||||
success_rate: number
|
||||
avg_duration_ms: number | null
|
||||
by_operation_type: Record<string, number>
|
||||
by_namespace: Record<string, number>
|
||||
last_24h_count: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Helper
|
||||
// =============================================================================
|
||||
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
// 統帥鐵律: 禁止任何 Fallback IP
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||||
return ''
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stat Card Component
|
||||
// =============================================================================
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
variant = 'default',
|
||||
}: {
|
||||
icon: typeof Activity
|
||||
label: string
|
||||
value: string | number
|
||||
subValue?: string
|
||||
variant?: 'default' | 'success' | 'warning'
|
||||
}) {
|
||||
export default function ActionLogPage({ params }: { params: { locale: string } }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
'bg-white/50 backdrop-blur-sm',
|
||||
variant === 'success' && 'border-status-healthy/30',
|
||||
variant === 'warning' && 'border-status-warning/30',
|
||||
variant === 'default' && 'border-nothing-gray-200'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
variant === 'success' && 'bg-status-healthy/10',
|
||||
variant === 'warning' && 'bg-status-warning/10',
|
||||
variant === 'default' && 'bg-nothing-gray-100'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-5 h-5',
|
||||
variant === 'success' && 'text-status-healthy',
|
||||
variant === 'warning' && 'text-status-warning',
|
||||
variant === 'default' && 'text-nothing-gray-600'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-nothing-gray-500 font-body uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-xl font-bold text-nothing-black">{value}</p>
|
||||
{subValue && (
|
||||
<p className="text-[10px] text-nothing-gray-400 font-body">
|
||||
{subValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export default function ActionLogPage({
|
||||
params,
|
||||
}: {
|
||||
params: { locale: string }
|
||||
}) {
|
||||
const t = useTranslations()
|
||||
const locale = params.locale
|
||||
|
||||
// State
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [stats, setStats] = useState<AuditStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
|
||||
// #19 P2: AbortController 防止 unmount 時記憶體洩漏
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
// ==========================================================================
|
||||
// Fetch Audit Logs
|
||||
// ==========================================================================
|
||||
const fetchLogs = useCallback(async (pageNum: number) => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
// 取消前一次未完成的請求
|
||||
abortControllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/api/v1/audit-logs?page=${pageNum}&page_size=10`,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: AuditLogListResponse = await response.json()
|
||||
setLogs(data.logs)
|
||||
setPage(data.page)
|
||||
setTotalPages(data.total_pages)
|
||||
setTotalCount(data.count)
|
||||
} catch (err) {
|
||||
// 忽略 AbortError (正常行為)
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
setError(message)
|
||||
console.error('[ActionLog] Fetch error:', message) // eslint-disable-line no-console
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ==========================================================================
|
||||
// Fetch Stats
|
||||
// ==========================================================================
|
||||
const fetchStats = useCallback(async () => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/audit-logs/stats`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: abortControllerRef.current?.signal,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: AuditStats = await response.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略 AbortError
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
console.error('[ActionLog] Stats fetch error:', err) // eslint-disable-line no-console
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ==========================================================================
|
||||
// Initial Fetch + Cleanup
|
||||
// ==========================================================================
|
||||
useEffect(() => {
|
||||
fetchLogs(1)
|
||||
fetchStats()
|
||||
|
||||
// #19 P2: Cleanup - 組件 unmount 時取消所有請求
|
||||
return () => {
|
||||
abortControllerRef.current?.abort()
|
||||
}
|
||||
}, [fetchLogs, fetchStats])
|
||||
|
||||
// ==========================================================================
|
||||
// Pagination Handlers
|
||||
// ==========================================================================
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) {
|
||||
fetchLogs(page - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (page < totalPages) {
|
||||
fetchLogs(page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Format Helpers
|
||||
// ==========================================================================
|
||||
const formatDate = (isoString: string) => {
|
||||
try {
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString(locale === 'zh-TW' ? 'zh-TW' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number | null) => {
|
||||
if (ms === null) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Render
|
||||
// ==========================================================================
|
||||
return (
|
||||
<AppLayout locale={locale}>
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h2 className="font-heading text-2xl font-bold text-nothing-black">
|
||||
{t('actionLog.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-nothing-gray-500">
|
||||
{t('actionLog.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="mb-6 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label={t('actionLog.stats.total')}
|
||||
value={stats.total_executions}
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label={t('actionLog.stats.successRate')}
|
||||
value={`${stats.success_rate}%`}
|
||||
variant="success"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label={t('actionLog.stats.avgDuration')}
|
||||
value={stats.avg_duration_ms ? `${Math.round(stats.avg_duration_ms)}ms` : '-'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Zap}
|
||||
label={t('actionLog.stats.last24h')}
|
||||
value={stats.last_24h_count}
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<DataPincerPanel title={t('actionLog.title')} status="healthy">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="text-sm text-nothing-gray-500 font-body">
|
||||
{totalCount > 0
|
||||
? `${totalCount} ${t('actionLog.columns.operation').toLowerCase()}s`
|
||||
: ''}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetchLogs(page)
|
||||
fetchStats()
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-body',
|
||||
'bg-nothing-gray-100 text-nothing-gray-600',
|
||||
'hover:bg-nothing-gray-200 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-3 h-3', isLoading && 'animate-spin')}
|
||||
/>
|
||||
{t('common.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 mb-4 rounded-lg bg-status-critical/10 border border-status-critical/20">
|
||||
<AlertCircle className="w-4 h-4 text-status-critical" />
|
||||
<span className="text-sm text-status-critical font-body">
|
||||
{t('actionLog.fetchError')}: {error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && logs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 text-nothing-gray-400 animate-spin" />
|
||||
<span className="ml-2 text-nothing-gray-400 font-body">
|
||||
{t('actionLog.loading')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && logs.length === 0 && !error && (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-nothing-gray-300" />
|
||||
<p className="text-nothing-gray-400 font-body">
|
||||
{t('actionLog.noLogs')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Table */}
|
||||
{logs.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-nothing-gray-200">
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.time')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.operation')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.target')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.namespace')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.status')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.duration')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.executor')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="border-b border-nothing-gray-100 hover:bg-nothing-gray-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-3 font-body text-xs text-nothing-gray-600">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-[10px] font-body font-bold uppercase',
|
||||
log.operation_type === 'DELETE_POD' &&
|
||||
'bg-status-critical/10 text-status-critical',
|
||||
log.operation_type === 'RESTART_DEPLOYMENT' &&
|
||||
'bg-status-warning/10 text-status-warning',
|
||||
log.operation_type === 'SCALE_DEPLOYMENT' &&
|
||||
'bg-claw-blue/10 text-claw-blue'
|
||||
)}
|
||||
>
|
||||
{t(`actionLog.operations.${log.operation_type}` as never) ||
|
||||
log.operation_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 font-body text-xs text-nothing-black max-w-[200px] truncate">
|
||||
{log.target_resource}
|
||||
</td>
|
||||
<td className="px-3 py-3 font-body text-xs text-nothing-gray-600">
|
||||
{log.namespace}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{log.success ? (
|
||||
<span className="inline-flex items-center gap-1 text-status-healthy">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-[10px] font-body uppercase">
|
||||
{t('actionLog.status.success')}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-status-critical">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-[10px] font-body uppercase">
|
||||
{t('actionLog.status.failure')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-body text-xs text-nothing-gray-600">
|
||||
{formatDuration(log.execution_duration_ms)}
|
||||
</td>
|
||||
<td className="px-3 py-3 font-body text-xs text-nothing-gray-600">
|
||||
{log.executed_by}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 px-1">
|
||||
<span className="text-xs text-nothing-gray-500 font-body">
|
||||
{t('actionLog.pagination.page', {
|
||||
current: page,
|
||||
total: totalPages,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page <= 1 || isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-body',
|
||||
'border border-nothing-gray-200',
|
||||
'hover:bg-nothing-gray-50 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" />
|
||||
{t('actionLog.pagination.prev')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={page >= totalPages || isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-body',
|
||||
'border border-nothing-gray-200',
|
||||
'hover:bg-nothing-gray-50 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{t('actionLog.pagination.next')}
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DataPincerPanel>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 pt-6 border-t border-nothing-gray-200">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-nothing-gray-500">
|
||||
{t('footer.copyright')}
|
||||
</p>
|
||||
<p className="text-sm text-nothing-gray-400 font-body">
|
||||
{t('footer.poweredBy')} v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
<AppLayout locale={params.locale}>
|
||||
<ActionLogsPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,103 +1,18 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 應用 Page — 真實主機服務狀態
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/dashboard 真實數據
|
||||
* 應用 Page — 路由入口
|
||||
* @created 2026-04-01 ogt
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + AppsPanel
|
||||
*/
|
||||
|
||||
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 HostService {
|
||||
name: string
|
||||
status: string
|
||||
port: number | null
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
interface Host {
|
||||
ip: string
|
||||
name: string
|
||||
status: string
|
||||
services: HostService[]
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
up: '#22C55E',
|
||||
healthy: '#22C55E',
|
||||
down: '#cc2200',
|
||||
degraded: '#F59E0B',
|
||||
unreachable: '#87867f',
|
||||
}
|
||||
import { AppsPanel } from '@/components/panels/AppsPanel'
|
||||
|
||||
export default function AppsPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('apps')
|
||||
const [hosts, setHosts] = useState<Host[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/dashboard`)
|
||||
.then(r => r.json())
|
||||
.then(data => { setHosts(data.hosts ?? []); setLoading(false) })
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
const allServices = hosts.flatMap(h =>
|
||||
h.services.map(s => ({ ...s, hostName: h.name, hostIp: h.ip }))
|
||||
)
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
|
||||
<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: '#22C55E', display: 'inline-block' }} />
|
||||
{t('title')} ({loading ? '...' : allServices.length})
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
|
||||
) : allServices.length === 0 ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noApps')}</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#faf9f3' }}>
|
||||
{[t('service'), t('host'), t('port'), t('latency'), 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>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allServices.map((s, i) => (
|
||||
<tr key={i} style={{ borderBottom: '0.5px solid #f0ede4' }}>
|
||||
<td style={{ padding: '8px 14px', fontWeight: 500, color: '#141413' }}>{s.name}</td>
|
||||
<td style={{ padding: '8px 14px', color: '#87867f', fontSize: 12 }}>{s.hostName} <span style={{ color: '#c0bdb4' }}>({s.hostIp})</span></td>
|
||||
<td style={{ padding: '8px 14px', color: '#87867f' }}>{s.port ?? '—'}</td>
|
||||
<td style={{ padding: '8px 14px', color: '#87867f' }}>{s.latency_ms != null ? `${s.latency_ms.toFixed(0)}ms` : '—'}</td>
|
||||
<td style={{ padding: '8px 14px' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: STATUS_COLOR[s.status] ?? '#87867f' }}>
|
||||
<span style={{ width: 5, height: 5, borderRadius: '50%', background: STATUS_COLOR[s.status] ?? '#87867f', display: 'inline-block' }} />
|
||||
{s.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AppsPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,460 +1,17 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Auto Repair Page - 自動修復
|
||||
* ============================
|
||||
* 顯示自動修復統計 + Incident 評估 + 執行紀錄
|
||||
* 資料來源:
|
||||
* GET /api/v1/auto-repair/stats
|
||||
* GET /api/v1/auto-repair/evaluate/{incident_id}
|
||||
* GET /api/v1/incidents (活躍 incident 清單)
|
||||
*
|
||||
* @updated 2026-04-01 ogt - 從佔位符升級為完整頁面
|
||||
* @updated 2026-04-02 Claude Code - i18n 合規修復 (零硬編碼)
|
||||
* Auto Repair Page — 路由入口
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + AutoRepairPanel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { useIncidents } from '@/hooks/useIncidents'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Wrench, RefreshCw, AlertCircle,
|
||||
CheckCircle2, XCircle, ShieldAlert,
|
||||
Play, ChevronDown, ChevronUp, Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
// 2026-04-07 Claude Code: Sprint 4 C2 — disposition_summary 擴充
|
||||
interface DispositionSummary {
|
||||
auto_repair: number
|
||||
human_approved: number
|
||||
manual_resolved: number
|
||||
cold_start_trust: number
|
||||
total: number
|
||||
auto_rate: number
|
||||
}
|
||||
|
||||
interface AutoRepairStats {
|
||||
approved_playbooks: number
|
||||
high_quality_playbooks: number
|
||||
total_executions: number
|
||||
overall_success_rate: number
|
||||
auto_repair_eligible: boolean
|
||||
disposition_summary?: DispositionSummary
|
||||
}
|
||||
|
||||
interface EvaluateResponse {
|
||||
can_auto_repair: boolean
|
||||
playbook_id: string | null
|
||||
playbook_name: string | null
|
||||
reason: string
|
||||
risk_level: string
|
||||
blocked_by: string | null
|
||||
success_rate: number | null
|
||||
total_executions: number | null
|
||||
}
|
||||
|
||||
interface ExecuteResponse {
|
||||
success: boolean
|
||||
incident_id: string
|
||||
playbook_id: string
|
||||
executed_steps: string[]
|
||||
error: string | null
|
||||
execution_time_ms: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
const getApiBaseUrl = () => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL') // eslint-disable-line no-console
|
||||
return url ?? ''
|
||||
}
|
||||
|
||||
const RISK_STYLE: Record<string, string> = {
|
||||
LOW: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20',
|
||||
MEDIUM: 'bg-status-warning/10 text-status-warning border-status-warning/20',
|
||||
HIGH: 'bg-status-critical/10 text-status-critical border-status-critical/20',
|
||||
CRITICAL: 'bg-status-critical/10 text-status-critical border-status-critical/20',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
||||
function StatCard({
|
||||
label, value, sub, highlight,
|
||||
}: { label: string; value: string | number; sub?: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border p-4',
|
||||
highlight ? 'bg-claw-blue/5 border-claw-blue/20' : 'bg-white border-nothing-gray-200'
|
||||
)}>
|
||||
<p className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={cn('text-2xl font-bold font-body tabular-nums', highlight ? 'text-claw-blue' : 'text-nothing-black')}>
|
||||
{value}
|
||||
</p>
|
||||
{sub && <p className="text-[11px] font-body text-nothing-gray-400 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Incident Evaluate Row
|
||||
// =============================================================================
|
||||
|
||||
function IncidentEvalRow({
|
||||
incidentId, severity,
|
||||
}: { incidentId: string; severity: string }) {
|
||||
const t = useTranslations('autoRepair')
|
||||
const [eval_, setEval] = useState<EvaluateResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [executing, setExecuting] = useState(false)
|
||||
const [result, setResult] = useState<ExecuteResponse | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const fetchEval = useCallback(async () => {
|
||||
const base = getApiBaseUrl()
|
||||
if (!base) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/auto-repair/evaluate/${incidentId}`)
|
||||
if (res.ok) {
|
||||
setEval(await res.json())
|
||||
}
|
||||
} catch {
|
||||
// API 不可用時靜默處理,不噴 console error
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [incidentId])
|
||||
|
||||
useEffect(() => { fetchEval() }, [fetchEval])
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!eval_?.playbook_id) return
|
||||
setExecuting(true)
|
||||
try {
|
||||
const base = getApiBaseUrl()
|
||||
const res = await fetch(`${base}/api/v1/auto-repair/execute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ incident_id: incidentId, playbook_id: eval_.playbook_id }),
|
||||
})
|
||||
if (res.ok) setResult(await res.json())
|
||||
} finally {
|
||||
setExecuting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-nothing-gray-200 rounded-lg overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 bg-nothing-gray-50 cursor-pointer hover:bg-nothing-gray-100 transition-colors"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
>
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 rounded font-body text-[11px] font-bold',
|
||||
severity === 'P0' ? 'bg-status-critical/10 text-status-critical' :
|
||||
severity === 'P1' ? 'bg-status-warning/10 text-status-warning' :
|
||||
'bg-nothing-gray-100 text-nothing-gray-600'
|
||||
)}>{severity}</span>
|
||||
<span className="font-body text-sm text-nothing-black flex-1 truncate">{incidentId}</span>
|
||||
|
||||
{loading && <RefreshCw className="w-4 h-4 animate-spin text-nothing-gray-400" />}
|
||||
{!loading && eval_ && (
|
||||
eval_.can_auto_repair
|
||||
? <span className="flex items-center gap-1 text-[11px] font-body text-status-healthy"><CheckCircle2 className="w-3.5 h-3.5" />{t('canAutoRepair')}</span>
|
||||
: <span className="flex items-center gap-1 text-[11px] font-body text-nothing-gray-400"><XCircle className="w-3.5 h-3.5" />{t('notEligibleShort')}</span>
|
||||
)}
|
||||
{expanded ? <ChevronUp className="w-4 h-4 text-nothing-gray-400" /> : <ChevronDown className="w-4 h-4 text-nothing-gray-400" />}
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && eval_ && (
|
||||
<div className="px-4 py-4 bg-white space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">Playbook</span>
|
||||
<p className="font-body text-nothing-black mt-0.5">{eval_.playbook_name ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">{t('riskLevel')}</span>
|
||||
<p className="mt-0.5">
|
||||
<span className={cn('px-2 py-0.5 rounded border text-[11px] font-body font-bold', RISK_STYLE[eval_.risk_level] ?? RISK_STYLE.MEDIUM)}>
|
||||
{eval_.risk_level}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{eval_.success_rate != null && (
|
||||
<div>
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">{t('successRate')}</span>
|
||||
<p className="font-body text-status-healthy font-bold mt-0.5">{(eval_.success_rate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
{eval_.total_executions != null && (
|
||||
<div>
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">{t('execCount')}</span>
|
||||
<p className="font-body text-nothing-black mt-0.5">{eval_.total_executions}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-nothing-gray-50 rounded-lg">
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">{t('decisionReason')}</span>
|
||||
<p className="text-sm font-body text-nothing-gray-700 mt-1">{eval_.reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Execute result */}
|
||||
{result && (
|
||||
<div className={cn(
|
||||
'p-3 rounded-lg border',
|
||||
result.success ? 'bg-status-healthy/5 border-status-healthy/20' : 'bg-status-critical/5 border-status-critical/20'
|
||||
)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{result.success
|
||||
? <CheckCircle2 className="w-4 h-4 text-status-healthy" />
|
||||
: <XCircle className="w-4 h-4 text-status-critical" />}
|
||||
<span className={cn('text-sm font-body font-bold', result.success ? 'text-status-healthy' : 'text-status-critical')}>
|
||||
{result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error })}
|
||||
</span>
|
||||
</div>
|
||||
{result.executed_steps.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{result.executed_steps.map((step, i) => (
|
||||
<li key={i} className="text-xs font-body text-nothing-gray-600 flex items-start gap-1.5">
|
||||
<Zap className="w-3 h-3 mt-0.5 text-claw-blue flex-shrink-0" />
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execute button */}
|
||||
{eval_.can_auto_repair && !result && (
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executing}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg',
|
||||
'bg-claw-blue text-white font-body text-sm font-semibold',
|
||||
'hover:bg-claw-blue/90 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{executing
|
||||
? <><RefreshCw className="w-4 h-4 animate-spin" />{t('executing')}</>
|
||||
: <><Play className="w-4 h-4" />{t('execute')}</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page
|
||||
// =============================================================================
|
||||
import { AutoRepairPanel } from '@/components/panels/AutoRepairPanel'
|
||||
|
||||
export default function AutoRepairPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('autoRepair')
|
||||
const tNav = useTranslations('nav')
|
||||
const tCommon = useTranslations('common')
|
||||
|
||||
const [stats, setStats] = useState<AutoRepairStats | null>(null)
|
||||
const [statsLoading, setStatsLoading] = useState(true)
|
||||
const [statsError, setStatsError] = useState<string | null>(null)
|
||||
const [disposition, setDisposition] = useState<{ total: number; auto_repair: number; human_approved: number; manual_resolved: number; cold_start_trust: number; auto_rate: number } | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const { incidents, isLoading: incidentsLoading } = useIncidents({
|
||||
pollInterval: 30000,
|
||||
enablePolling: true,
|
||||
})
|
||||
|
||||
const eligibleIncidents = (incidents ?? []).filter(i =>
|
||||
i.severity === 'P1' || i.severity === 'P2'
|
||||
)
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
const base = getApiBaseUrl()
|
||||
if (!base) return
|
||||
abortRef.current?.abort()
|
||||
const ctrl = new AbortController()
|
||||
abortRef.current = ctrl
|
||||
setStatsLoading(true)
|
||||
setStatsError(null)
|
||||
try {
|
||||
const [res, dispRes] = await Promise.all([
|
||||
fetch(`${base}/api/v1/auto-repair/stats`, { signal: ctrl.signal }),
|
||||
fetch(`${base}/api/v1/stats/disposition`, { signal: ctrl.signal }).catch(() => null),
|
||||
])
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setStats(await res.json())
|
||||
if (dispRes?.ok) {
|
||||
const d = await dispRes.json()
|
||||
setDisposition(d.summary ?? null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') return
|
||||
setStatsError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
setStatsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
return () => { abortRef.current?.abort() }
|
||||
}, [fetchStats])
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-bold text-nothing-black flex items-center gap-2">
|
||||
<Wrench className="w-6 h-6" />
|
||||
{tNav('autoRepair')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-nothing-gray-500 font-body">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchStats}
|
||||
disabled={statsLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||||
'text-xs font-body bg-nothing-gray-100 text-nothing-gray-600',
|
||||
'hover:bg-nothing-gray-200 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', statsLoading && 'animate-spin')} />
|
||||
{tCommon('refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{statsError && (
|
||||
<div className="mb-4 p-4 rounded-lg bg-status-critical/10 border border-status-critical/20 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-status-critical" />
|
||||
<span className="text-sm font-body text-status-critical">{statsError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
label={t('approvedPlaybooks')}
|
||||
value={stats.approved_playbooks}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('highQualityPlaybooks')}
|
||||
value={stats.high_quality_playbooks}
|
||||
sub={t('highQualitySub')}
|
||||
highlight
|
||||
/>
|
||||
<StatCard
|
||||
label={t('totalExecutions')}
|
||||
value={stats.total_executions}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('overallSuccessRate')}
|
||||
value={`${(stats.overall_success_rate * 100).toFixed(1)}%`}
|
||||
sub={stats.auto_repair_eligible ? t('eligible') : t('notEligible')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprint 4 E3: 處置概況 */}
|
||||
{disposition && disposition.total > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2 mb-6">
|
||||
<div className="rounded-lg border border-green-500/25 bg-green-500/5 p-3 text-center">
|
||||
<p className="text-[10px] font-bold text-green-500 uppercase tracking-wider">{t('dispositionAuto')}</p>
|
||||
<p className="text-xl font-bold text-green-500 tabular-nums mt-1">{disposition.auto_repair}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-orange-500/25 bg-orange-500/5 p-3 text-center">
|
||||
<p className="text-[10px] font-bold text-orange-500 uppercase tracking-wider">{t('dispositionHuman')}</p>
|
||||
<p className="text-xl font-bold text-orange-500 tabular-nums mt-1">{disposition.human_approved}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-purple-500/25 bg-purple-500/5 p-3 text-center">
|
||||
<p className="text-[10px] font-bold text-purple-500 uppercase tracking-wider">{t('dispositionManual')}</p>
|
||||
<p className="text-xl font-bold text-purple-500 tabular-nums mt-1">{disposition.manual_resolved}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-blue-500/25 bg-blue-500/5 p-3 text-center">
|
||||
<p className="text-[10px] font-bold text-blue-500 uppercase tracking-wider">{t('dispositionCold')}</p>
|
||||
<p className="text-xl font-bold text-blue-500 tabular-nums mt-1">{disposition.cold_start_trust}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Eligible indicator */}
|
||||
{stats && (
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 p-4 rounded-lg border mb-6',
|
||||
stats.auto_repair_eligible
|
||||
? 'bg-status-healthy/5 border-status-healthy/20'
|
||||
: 'bg-nothing-gray-50 border-nothing-gray-200'
|
||||
)}>
|
||||
{stats.auto_repair_eligible
|
||||
? <CheckCircle2 className="w-5 h-5 text-status-healthy" />
|
||||
: <ShieldAlert className="w-5 h-5 text-nothing-gray-400" />}
|
||||
<div>
|
||||
<p className={cn('text-sm font-body font-semibold', stats.auto_repair_eligible ? 'text-status-healthy' : 'text-nothing-gray-600')}>
|
||||
{stats.auto_repair_eligible ? t('ready') : t('notReady')}
|
||||
</p>
|
||||
<p className="text-xs font-body text-nothing-gray-400">
|
||||
{stats.auto_repair_eligible
|
||||
? t('readyDesc', { count: stats.high_quality_playbooks })
|
||||
: t('notReadyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incident evaluation list */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-widest mb-3">
|
||||
{t('incidentEval')}
|
||||
</h3>
|
||||
|
||||
{incidentsLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-nothing-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!incidentsLoading && eligibleIncidents.length === 0 && (
|
||||
<div className="text-center py-10 border border-dashed border-nothing-gray-200 rounded-lg">
|
||||
<CheckCircle2 className="w-8 h-8 text-status-healthy mx-auto mb-2" />
|
||||
<p className="font-body text-sm text-nothing-gray-500">{t('noEligible')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{eligibleIncidents.map(incident => (
|
||||
<IncidentEvalRow
|
||||
key={incident.incident_id}
|
||||
incidentId={incident.incident_id}
|
||||
severity={incident.severity}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AutoRepairPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,113 +1,18 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 使用量 Page — 系統操作使用量統計
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/audit-logs/stats 真實數據
|
||||
* 使用量 Page — 路由入口
|
||||
* @created 2026-04-01 ogt
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + BillingPanel
|
||||
*/
|
||||
|
||||
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>
|
||||
}
|
||||
import { BillingPanel } from '@/components/panels/BillingPanel'
|
||||
|
||||
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}>
|
||||
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
<BillingPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,95 +1,18 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 成本分析 Page — AI 執行效能統計
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/stats/ai-performance 真實數據
|
||||
* 成本分析 Page — 路由入口
|
||||
* @created 2026-04-01 ogt
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + CostPanel
|
||||
*/
|
||||
|
||||
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>
|
||||
}
|
||||
import { CostPanel } from '@/components/panels/CostPanel'
|
||||
|
||||
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}>
|
||||
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</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>
|
||||
<CostPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,113 +1,18 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 部署管理 Page — K3s 服務部署狀態
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/dashboard 真實數據
|
||||
* 部署管理 Page — 路由入口
|
||||
* @created 2026-04-01 ogt
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + DeploymentsPanel
|
||||
*/
|
||||
|
||||
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 HostService {
|
||||
name: string
|
||||
status: string
|
||||
port: number | null
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
interface Host {
|
||||
ip: string
|
||||
name: string
|
||||
role: string
|
||||
status: string
|
||||
services: HostService[]
|
||||
last_check: string
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
up: '#22C55E',
|
||||
healthy: '#22C55E',
|
||||
down: '#cc2200',
|
||||
degraded: '#F59E0B',
|
||||
unreachable: '#87867f',
|
||||
}
|
||||
import { DeploymentsPanel } from '@/components/panels/DeploymentsPanel'
|
||||
|
||||
export default function DeploymentsPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('deployments')
|
||||
const [hosts, setHosts] = useState<Host[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/dashboard`)
|
||||
.then(r => r.json())
|
||||
.then(data => { setHosts(data.hosts ?? []); setLoading(false) })
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
const k3sHosts = hosts.filter(h => h.role === 'k3s' || h.ip.includes('120'))
|
||||
const displayHosts = k3sHosts.length > 0 ? k3sHosts : hosts
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</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>
|
||||
) : displayHosts.length === 0 ? (
|
||||
<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('noDeployments')}</div>
|
||||
) : (
|
||||
displayHosts.map(host => (
|
||||
<div key={host.ip} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLOR[host.status] ?? '#87867f', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{host.name}</span>
|
||||
<span style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>{host.ip}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>
|
||||
{host.last_check ? new Date(host.last_check).toLocaleTimeString('zh-TW', { timeZone: 'Asia/Taipei' }) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#faf9f3' }}>
|
||||
{[t('service'), t('port'), t('latency'), t('status')].map(col => (
|
||||
<th key={col} style={{ padding: '6px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{host.services.length === 0 ? (
|
||||
<tr><td colSpan={4} style={{ padding: '16px 14px', textAlign: 'center', color: '#87867f', fontSize: 12 }}>{t('noDeployments')}</td></tr>
|
||||
) : host.services.map((s, i) => (
|
||||
<tr key={i} style={{ borderBottom: '0.5px solid #f0ede4' }}>
|
||||
<td style={{ padding: '7px 14px', fontWeight: 500, color: '#141413' }}>{s.name}</td>
|
||||
<td style={{ padding: '7px 14px', color: '#87867f' }}>{s.port ?? '—'}</td>
|
||||
<td style={{ padding: '7px 14px', color: '#87867f' }}>{s.latency_ms != null ? `${s.latency_ms.toFixed(0)}ms` : '—'}</td>
|
||||
<td style={{ padding: '7px 14px' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: STATUS_COLOR[s.status] ?? '#87867f' }}>
|
||||
<span style={{ width: 5, height: 5, borderRadius: '50%', background: STATUS_COLOR[s.status] ?? '#87867f', display: 'inline-block' }} />
|
||||
{s.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DeploymentsPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,324 +1,17 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Config Drift Detection Page - 配置漂移偵測
|
||||
* =============================================
|
||||
* Phase 25 P2: GitOps 守門員
|
||||
* 偵測 K8s 實際狀態 vs Git YAML 漂移
|
||||
*
|
||||
* API: /api/v1/drift/scan, /api/v1/drift/reports
|
||||
* CronJob: drift-scanner (每小時自動)
|
||||
*
|
||||
* 建立時間: 2026-04-04 (台北時區)
|
||||
* 建立者: Claude Code (Phase 25 P2)
|
||||
* 關聯設計: docs/superpowers/specs/2026-04-04-nemotron-active-defense-design.md 方向三
|
||||
* Config Drift Detection Page — 路由入口
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + DriftPanel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Diff, RefreshCw, AlertTriangle, CheckCircle2,
|
||||
Clock, Terminal, GitMerge, Info,
|
||||
} from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface DriftReport {
|
||||
report_id: string
|
||||
scanned_at: string
|
||||
namespace: string
|
||||
triggered_by: string
|
||||
high_count: number
|
||||
medium_count: number
|
||||
info_count: number
|
||||
interpretation: string | null
|
||||
status: 'pending' | 'resolved' | 'ignored'
|
||||
created_at: string
|
||||
resolved_at: string | null
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
report_id: string
|
||||
summary: string
|
||||
high_count: number
|
||||
medium_count: number
|
||||
info_count: number
|
||||
has_critical_drift: boolean
|
||||
interpretation: string | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
const getApiBase = () => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
}
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-TW', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
||||
function DriftLevelBadge({ high, medium, info, t }: {
|
||||
high: number; medium: number; info: number
|
||||
t: (k: string) => string
|
||||
}) {
|
||||
if (high === 0 && medium === 0 && info === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-status-healthy/10 text-status-healthy border border-status-healthy/20">
|
||||
<CheckCircle2 size={10} />
|
||||
{t('noDrift')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{high > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-status-critical/10 text-status-critical border border-status-critical/20">
|
||||
<AlertTriangle size={10} />
|
||||
{t('highCount')} {high}
|
||||
</span>
|
||||
)}
|
||||
{medium > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-status-warning/10 text-status-warning border border-status-warning/20">
|
||||
<Info size={10} />
|
||||
{t('mediumCount')} {medium}
|
||||
</span>
|
||||
)}
|
||||
{info > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-neutral-100 text-neutral-500 border border-neutral-200">
|
||||
{t('infoCount')} {info}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status, t }: { status: DriftReport['status']; t: (k: string) => string }) {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'bg-status-warning/10 text-status-warning border-status-warning/20',
|
||||
resolved: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20',
|
||||
ignored: 'bg-neutral-100 text-neutral-400 border-neutral-200',
|
||||
}
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium border',
|
||||
styles[status] ?? styles.ignored
|
||||
)}>
|
||||
{t(status)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Page
|
||||
// =============================================================================
|
||||
import { DriftPanel } from '@/components/panels/DriftPanel'
|
||||
|
||||
export default function DriftPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('drift')
|
||||
const [reports, setReports] = useState<DriftReport[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchReports = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/v1/drift/reports?limit=20`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setReports(data.items ?? [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to fetch')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchReports() }, [fetchReports])
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true)
|
||||
setScanResult(null)
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/v1/drift/scan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ namespaces: ['awoooi-prod'], triggered_by: 'web_manual' }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data: ScanResult = await res.json()
|
||||
setScanResult(data)
|
||||
// Refresh reports list
|
||||
await fetchReports()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Scan failed')
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<div className="flex flex-col h-full overflow-auto bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<Diff size={18} className="text-neutral-400" />
|
||||
<div>
|
||||
<h1 className="text-[13px] font-semibold text-neutral-800 leading-tight">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="text-[11px] text-neutral-400 leading-tight mt-0.5">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchReports}
|
||||
disabled={loading}
|
||||
className="p-1.5 rounded text-neutral-400 hover:text-neutral-600 hover:bg-neutral-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded text-[11px] font-medium transition-colors',
|
||||
scanning
|
||||
? 'bg-neutral-100 text-neutral-400 cursor-not-allowed'
|
||||
: 'bg-neutral-900 text-white hover:bg-neutral-700'
|
||||
)}
|
||||
>
|
||||
{scanning ? (
|
||||
<>
|
||||
<RefreshCw size={11} className="animate-spin" />
|
||||
{t('scanning')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge size={11} />
|
||||
{t('scan')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan Result Banner */}
|
||||
{scanResult && (
|
||||
<div className={cn(
|
||||
'mx-6 mt-4 px-4 py-3 rounded border text-[12px]',
|
||||
scanResult.has_critical_drift
|
||||
? 'bg-status-critical/5 border-status-critical/20 text-status-critical'
|
||||
: 'bg-status-healthy/5 border-status-healthy/20 text-status-healthy'
|
||||
)}>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{scanResult.has_critical_drift
|
||||
? <AlertTriangle size={13} />
|
||||
: <CheckCircle2 size={13} />
|
||||
}
|
||||
{scanResult.summary}
|
||||
{(scanResult.high_count > 0 || scanResult.medium_count > 0) && (
|
||||
<span className="ml-2 text-neutral-500 font-normal">
|
||||
— {t('highCount')} {scanResult.high_count}, {t('mediumCount')} {scanResult.medium_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{scanResult.interpretation && (
|
||||
<p className="mt-1.5 text-neutral-600 font-normal pl-5">
|
||||
{scanResult.interpretation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 px-4 py-3 rounded border border-status-critical/20 bg-status-critical/5 text-status-critical text-[12px]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 px-6 py-4">
|
||||
{loading && reports.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-neutral-400">
|
||||
<RefreshCw size={16} className="animate-spin mr-2" />
|
||||
<span className="text-[12px]">{t('loading')}</span>
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-neutral-400">
|
||||
<Terminal size={32} className="mb-3 opacity-30" />
|
||||
<p className="text-[13px] font-medium text-neutral-500">{t('noReports')}</p>
|
||||
<p className="text-[11px] text-neutral-400 mt-1 text-center max-w-xs">{t('noReportsHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reports.map((report) => (
|
||||
<div
|
||||
key={report.report_id}
|
||||
className="border border-neutral-100 rounded-lg px-4 py-3 hover:border-neutral-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Left: ID + time */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<code className="text-[11px] font-mono text-neutral-500 bg-neutral-50 px-1.5 py-0.5 rounded shrink-0">
|
||||
{report.report_id.slice(0, 8)}
|
||||
</code>
|
||||
<DriftLevelBadge
|
||||
high={report.high_count}
|
||||
medium={report.medium_count}
|
||||
info={report.info_count}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
{/* Right: status + time */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={report.status} t={t} />
|
||||
<span className="text-[11px] text-neutral-400 flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{fmtTime(report.scanned_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Interpretation */}
|
||||
{report.interpretation && (
|
||||
<p className="mt-2 text-[11px] text-neutral-500 pl-1 border-l-2 border-neutral-100">
|
||||
{report.interpretation}
|
||||
</p>
|
||||
)}
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 mt-2 text-[10px] text-neutral-400">
|
||||
<span>{t('namespace')}: <span className="font-mono">{report.namespace}</span></span>
|
||||
<span>{t('triggeredBy')}: {report.triggered_by}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DriftPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,209 +1,17 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Neural Command Center - 神經指揮中心
|
||||
* =====================================
|
||||
* SSH_COMMAND 指揮權鏈完整監控頁面
|
||||
*
|
||||
* 功能:
|
||||
* - Pre-Flight 安全審查面板 (8 項檢查)
|
||||
* - 即時指揮中心 (OpenClaw 🦞 + NemoTron ⚡)
|
||||
* - 統計 & 歷史數據
|
||||
* - 核鑰授權面板
|
||||
*
|
||||
* API:
|
||||
* GET /api/v1/auto-repair/stats
|
||||
* GET /api/v1/playbooks/
|
||||
* GET /api/v1/auto-repair/history
|
||||
* GET /api/v1/approvals (pending)
|
||||
*
|
||||
* 建立時間: 2026-04-06 (台北時區)
|
||||
* 建立者: Claude Code (Sprint 3 SSH_COMMAND 指揮權鏈)
|
||||
* Neural Command Center Page — 路由入口
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + NeuralCommandPanel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
BrainCircuit, Zap, ShieldCheck, CheckCircle2, AlertTriangle,
|
||||
XCircle, RefreshCw, Clock, Terminal, Database,
|
||||
ChevronRight, Activity, Lock, Unlock,
|
||||
} from 'lucide-react'
|
||||
import { NeuralPreFlight } from '@/components/neural-command/NeuralPreFlight'
|
||||
import { NeuralLiveCenter } from '@/components/neural-command/NeuralLiveCenter'
|
||||
import { NeuralStats } from '@/components/neural-command/NeuralStats'
|
||||
import { NeuralApprovalPanel } from '@/components/neural-command/NeuralApprovalPanel'
|
||||
|
||||
import type { AutoRepairStats, PlaybookItem, RepairHistoryItem, NeuralTab, PendingApprovalItem, ActiveIncident } from '@/components/neural-command/types'
|
||||
export type { AutoRepairStats, PlaybookItem, RepairHistoryItem, NeuralTab }
|
||||
|
||||
// =============================================================================
|
||||
// Tab config
|
||||
// =============================================================================
|
||||
|
||||
const TABS: { id: NeuralTab; labelKey: string; Icon: React.ElementType }[] = [
|
||||
{ id: 'preflight', labelKey: 'preFlightAudit', Icon: ShieldCheck },
|
||||
{ id: 'live', labelKey: 'liveCommand', Icon: Activity },
|
||||
{ id: 'stats', labelKey: 'statsHistory', Icon: Database },
|
||||
{ id: 'approval', labelKey: 'nuclearApproval', Icon: Lock },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// Page
|
||||
// =============================================================================
|
||||
import { NeuralCommandPanel } from '@/components/panels/NeuralCommandPanel'
|
||||
|
||||
export default function NeuralCommandPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('neuralCommand')
|
||||
const [activeTab, setActiveTab] = useState<NeuralTab>('preflight')
|
||||
const [stats, setStats] = useState<AutoRepairStats | null>(null)
|
||||
const [playbooks, setPlaybooks] = useState<PlaybookItem[]>([])
|
||||
const [history, setHistory] = useState<RepairHistoryItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date())
|
||||
const [pendingApprovals, setPendingApprovals] = useState(0)
|
||||
const [pendingApprovalList, setPendingApprovalList] = useState<PendingApprovalItem[]>([])
|
||||
const [activeIncidents, setActiveIncidents] = useState<ActiveIncident[]>([])
|
||||
const [dispositionSummary, setDispositionSummary] = useState<{ total: number; auto_repair: number; human_approved: number; manual_resolved: number; cold_start_trust: number; auto_rate: number } | null>(null)
|
||||
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [statsRes, pbRes, histRes, approvalsRes, incidentsRes, dispRes] = await Promise.all([
|
||||
fetch('/api/v1/auto-repair/stats'),
|
||||
fetch('/api/v1/playbooks/'),
|
||||
fetch('/api/v1/auto-repair/history?limit=20'),
|
||||
fetch('/api/v1/approvals/pending'),
|
||||
fetch('/api/v1/incidents?status=firing&limit=10'),
|
||||
fetch('/api/v1/stats/disposition').catch(() => null),
|
||||
])
|
||||
|
||||
if (statsRes.ok) {
|
||||
const data = await statsRes.json()
|
||||
setStats(data)
|
||||
}
|
||||
if (pbRes.ok) {
|
||||
const data = await pbRes.json()
|
||||
setPlaybooks(data.items?.map((i: { playbook: PlaybookItem }) => i.playbook) ?? [])
|
||||
}
|
||||
if (histRes.ok) {
|
||||
const data = await histRes.json()
|
||||
setHistory(data.items ?? [])
|
||||
}
|
||||
if (approvalsRes.ok) {
|
||||
const data = await approvalsRes.json()
|
||||
setPendingApprovals(data.count ?? 0)
|
||||
setPendingApprovalList(data.approvals ?? [])
|
||||
}
|
||||
if (incidentsRes.ok) {
|
||||
const data = await incidentsRes.json()
|
||||
setActiveIncidents(data.incidents ?? [])
|
||||
}
|
||||
if (dispRes?.ok) {
|
||||
const data = await dispRes.json()
|
||||
setDispositionSummary(data.summary ?? null)
|
||||
}
|
||||
|
||||
setLastRefresh(new Date())
|
||||
} catch {
|
||||
// silently fail — show stale data
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
const interval = setInterval(fetchData, 30_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData])
|
||||
|
||||
const approvedPlaybooks = playbooks.filter(p => p.status === 'approved')
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
|
||||
{/* ── Page header ── */}
|
||||
<div className="flex items-start justify-between px-6 pt-5 pb-4 border-b border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-500/10 border border-orange-500/25 flex items-center justify-center">
|
||||
<BrainCircuit className="w-5 h-5 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">{t('title')}</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{/* Agent status pills */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-orange-500/30 bg-orange-500/5 text-orange-500 font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse" />
|
||||
OpenClaw
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-blue-500/30 bg-blue-500/5 text-blue-500 font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
NemoTron
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Refresh */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{t('lastRefresh', { time: lastRefresh.toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) })}</span>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="ml-1 p-1 rounded hover:bg-muted transition-colors"
|
||||
title={t('refresh')}
|
||||
>
|
||||
<RefreshCw className={cn('w-3 h-3', loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<div className="flex gap-1 px-6 py-2 border-b border-border flex-shrink-0">
|
||||
{TABS.map(({ id, labelKey, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||
activeTab === id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(labelKey)}
|
||||
{id === 'approval' && pendingApprovals > 0 && (
|
||||
<span className="ml-0.5 bg-orange-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full">
|
||||
{pendingApprovals}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tab content ── */}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{activeTab === 'preflight' && (
|
||||
<NeuralPreFlight stats={stats} playbooks={approvedPlaybooks} />
|
||||
)}
|
||||
{activeTab === 'live' && (
|
||||
<NeuralLiveCenter stats={stats} history={history} pendingCount={pendingApprovals} activeIncidents={activeIncidents} />
|
||||
)}
|
||||
{activeTab === 'stats' && (
|
||||
<NeuralStats stats={stats} playbooks={approvedPlaybooks} history={history} pendingCount={pendingApprovals} disposition={dispositionSummary} />
|
||||
)}
|
||||
{activeTab === 'approval' && (
|
||||
<NeuralApprovalPanel approvals={pendingApprovalList} onRefresh={fetchData} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<NeuralCommandPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,120 +1,18 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 服務目錄 Page
|
||||
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
|
||||
* @updated 2026-04-02 ogt - 升級為真實 UI,串接 /api/v1/dashboard
|
||||
* 服務目錄 Page — 路由入口
|
||||
* @created 2026-04-01 ogt
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + ServicesPanel
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
|
||||
interface ServiceItem {
|
||||
name: string
|
||||
host: string
|
||||
status: string
|
||||
cpu?: number
|
||||
ram?: number
|
||||
}
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
if (s === 'healthy' || s === 'running') return '#4caf50'
|
||||
if (s === 'warning') return '#ff9800'
|
||||
if (s === 'critical' || s === 'error') return '#f44336'
|
||||
return '#87867f'
|
||||
}
|
||||
import { ServicesPanel } from '@/components/panels/ServicesPanel'
|
||||
|
||||
export default function ServicesPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('services')
|
||||
const tc = useTranslations('common')
|
||||
const [services, setServices] = useState<ServiceItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
fetch(`${API_BASE}/api/v1/dashboard`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const hosts: { name: string; services?: { name: string; status: string; cpu?: number; ram?: number }[] }[] = data?.hosts ?? []
|
||||
const list: ServiceItem[] = []
|
||||
hosts.forEach(h => {
|
||||
(h.services ?? []).forEach(s => {
|
||||
list.push({ name: s.name, host: h.name, status: s.status, cpu: s.cpu, ram: s.ram })
|
||||
})
|
||||
})
|
||||
setServices(list)
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
|
||||
<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' }}>
|
||||
{/* Header */}
|
||||
<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>
|
||||
|
||||
{loading && (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
{tc('loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#f44336', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
{t('fetchError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && services.length === 0 && (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
{t('noServices')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && services.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#faf9f3' }}>
|
||||
{[t('name'), t('host'), t('status'), t('cpu'), t('ram')].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>
|
||||
{services.map((s, i) => (
|
||||
<tr key={i} style={{ borderBottom: '0.5px solid #e0ddd4' }}>
|
||||
<td style={{ padding: '10px 14px', color: '#141413', fontWeight: 500 }}>{s.name}</td>
|
||||
<td style={{ padding: '10px 14px', color: '#87867f' }}>{s.host}</td>
|
||||
<td style={{ padding: '10px 14px' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: statusColor(s.status), display: 'inline-block' }} />
|
||||
<span style={{ color: '#141413' }}>{s.status}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 14px', color: '#141413' }}>{s.cpu != null ? `${s.cpu}%` : '--'}</td>
|
||||
<td style={{ padding: '10px 14px', color: '#141413' }}>{s.ram != null ? `${s.ram}%` : '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ServicesPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,120 +1,18 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 工單 Page — 真實 Incidents 作為工單追蹤
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/incidents 真實數據
|
||||
* 工單 Page — 路由入口
|
||||
* @created 2026-04-01 ogt
|
||||
* @updated 2026-04-09 Claude Code - 精簡為 AppLayout + TicketsPanel
|
||||
*/
|
||||
|
||||
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 Incident {
|
||||
id: string
|
||||
title: string
|
||||
severity: string
|
||||
status: string
|
||||
created_at: string
|
||||
affected_service: string | null
|
||||
}
|
||||
|
||||
interface IncidentListResponse {
|
||||
incidents: Incident[]
|
||||
total: number
|
||||
}
|
||||
|
||||
const SEV_COLOR: Record<string, string> = {
|
||||
P0: '#cc2200',
|
||||
P1: '#F59E0B',
|
||||
P2: '#4A90D9',
|
||||
P3: '#22C55E',
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
open: '#cc2200',
|
||||
in_progress: '#F59E0B',
|
||||
resolved: '#22C55E',
|
||||
closed: '#87867f',
|
||||
}
|
||||
import { TicketsPanel } from '@/components/panels/TicketsPanel'
|
||||
|
||||
export default function TicketsPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('tickets')
|
||||
const [incidents, setIncidents] = useState<Incident[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/incidents`)
|
||||
.then(r => r.json())
|
||||
.then((data: IncidentListResponse) => {
|
||||
setIncidents(data.incidents ?? [])
|
||||
setTotal(data.total ?? 0)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
|
||||
<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')} ({loading ? '...' : total})
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
|
||||
) : incidents.length === 0 ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noTickets')}</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#faf9f3' }}>
|
||||
{[t('id'), t('title_col'), t('priority'), t('status'), t('createdAt')].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>
|
||||
{incidents.map((inc) => (
|
||||
<tr key={inc.id} style={{ borderBottom: '0.5px solid #f0ede4' }}>
|
||||
<td style={{ padding: '8px 14px', color: '#87867f', fontSize: 11, fontFamily: 'monospace' }}>{inc.id.slice(0, 8)}</td>
|
||||
<td style={{ padding: '8px 14px', fontWeight: 500, color: '#141413', maxWidth: 300 }}>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{inc.title}</div>
|
||||
{inc.affected_service && (
|
||||
<div style={{ fontSize: 11, color: '#87867f', marginTop: 2 }}>{inc.affected_service}</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 14px' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: SEV_COLOR[inc.severity] ?? '#87867f', background: `${SEV_COLOR[inc.severity] ?? '#87867f'}18`, border: `0.5px solid ${SEV_COLOR[inc.severity] ?? '#87867f'}40`, borderRadius: 4, padding: '1px 6px' }}>
|
||||
{inc.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '8px 14px' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: STATUS_COLOR[inc.status] ?? '#87867f' }}>
|
||||
{inc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '8px 14px', color: '#87867f', fontSize: 11 }}>
|
||||
{new Date(inc.created_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TicketsPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ function IncidentEvalRow({
|
||||
? <CheckCircle2 className="w-4 h-4 text-status-healthy" />
|
||||
: <XCircle className="w-4 h-4 text-status-critical" />}
|
||||
<span className={cn('text-sm font-body font-bold', result.success ? 'text-status-healthy' : 'text-status-critical')}>
|
||||
{result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error })}
|
||||
{result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error ?? '' })}
|
||||
</span>
|
||||
</div>
|
||||
{result.executed_steps.length > 0 && (
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
* Panel 元件匯出
|
||||
* Sprint 5: 供整合頁面使用的無 AppLayout 版本
|
||||
*
|
||||
* 已抽取:
|
||||
* - MonitoringPanel (from /monitoring)
|
||||
*
|
||||
* 待抽取 (暫時用 lazy import 原始頁面):
|
||||
* - APMPanel, ErrorsPanel, AppsPanel, ServicesPanel
|
||||
* - AutoRepairPanel, NeuralCommandPanel, DriftPanel
|
||||
* - DeploymentsPanel, TicketsPanel, CostPanel, ActionLogsPanel, BillingPanel
|
||||
* - SecurityPanel, CompliancePanel
|
||||
* 已抽取 (2026-04-09 全部完成):
|
||||
* - /observability: MonitoringPanel, ApmPanel, ErrorsPanel, AppsPanel, ServicesPanel
|
||||
* - /automation: AutoRepairPanel, NeuralCommandPanel, DriftPanel
|
||||
* - /operations: DeploymentsPanel, TicketsPanel, CostPanel, ActionLogsPanel, BillingPanel
|
||||
*/
|
||||
|
||||
export { MonitoringPanel } from './MonitoringPanel'
|
||||
|
||||
Reference in New Issue
Block a user