refactor(web): 全部 13 Panel 抽取完成 + 整合頁面雙重 AppLayout 修正
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

Panel 抽取 (13 個):
- MonitoringPanel, ApmPanel, ErrorsPanel, AppsPanel, ServicesPanel
- AutoRepairPanel, NeuralCommandPanel, DriftPanel
- DeploymentsPanel, TicketsPanel, CostPanel, ActionLogsPanel, BillingPanel

整合頁面更新 (全部使用 Panel,無雙重 AppLayout):
- /observability: 5 Panel
- /automation: 3 Panel
- /operations: 5 Panel

首席架構師 I2 問題已解決

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 11:05:37 +08:00
parent 9e10305acc
commit 7934ade3a6
14 changed files with 2135 additions and 47 deletions

View File

@@ -3,32 +3,25 @@
/**
* 自動化 (/automation) — Sprint 5 整合頁面
* 整合: 自動修復 + 神經指揮 + Drift 偵測
* 零假數據: 全部載入現有頁面內容
* 全部使用 Panel 元件 (無雙重 AppLayout)
* 建立時間: 2026-04-08 (台北時區)
* 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成
*/
import { lazy, Suspense } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { LobsterLoading } from '@/components/shared/lobster-loading'
const AutoRepairContent = lazy(() => import('@/app/[locale]/auto-repair/page'))
const NeuralCommandContent = lazy(() => import('@/app/[locale]/neural-command/page'))
const DriftContent = lazy(() => import('@/app/[locale]/drift/page'))
// C3 修正: 用 LobsterLoading 取代硬編碼「載入中」
function Loading() {
return <LobsterLoading size="sm" />
}
import { AutoRepairPanel } from '@/components/panels/AutoRepairPanel'
import { NeuralCommandPanel } from '@/components/panels/NeuralCommandPanel'
import { DriftPanel } from '@/components/panels/DriftPanel'
export default function AutomationPage({ params }: { params: { locale: string } }) {
const t = useTranslations('nav')
const tabs: TabConfig[] = [
{ id: 'repair', label: t('autoRepair'), content: <Suspense fallback={<Loading />}><AutoRepairContent params={params} /></Suspense> },
{ id: 'neural', label: t('neuralCommand'), content: <Suspense fallback={<Loading />}><NeuralCommandContent params={params} /></Suspense> },
{ id: 'drift', label: t('drift'), content: <Suspense fallback={<Loading />}><DriftContent params={params} /></Suspense> },
{ id: 'repair', label: t('autoRepair'), content: <AutoRepairPanel /> },
{ id: 'neural', label: t('neuralCommand'), content: <NeuralCommandPanel /> },
{ id: 'drift', label: t('drift'), content: <DriftPanel /> },
]
return (

View File

@@ -4,29 +4,22 @@
* 可觀測性 (/observability) — Sprint 5 整合頁面
* ================================================
* 整合: 服務監控 + APM + 錯誤追蹤 + 應用 + 服務目錄
*
* Tab 1 (monitoring) 使用 Panel 元件 (無雙重 AppLayout)
* Tab 2-5 暫時用 lazy import (未來逐步抽取 Panel)
* 全部使用 Panel 元件 (無雙重 AppLayout)
*
* 零假數據: 全部串接真實 API
*
* 建立時間: 2026-04-08 (台北時區)
* 更新時間: 2026-04-09 — Panel 抽取 (monitoring)
* 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成
*/
import { lazy, Suspense } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { MonitoringPanel } from '@/components/panels/MonitoringPanel'
import { ApmPanel } from '@/components/panels/ApmPanel'
import { ErrorsPanel } from '@/components/panels/ErrorsPanel'
import { LobsterLoading } from '@/components/shared/lobster-loading'
// Tab 4-5: 暫時 lazy import (未來抽取 Panel)
const ErrorsContent = lazy(() => import('@/app/[locale]/errors/page'))
const AppsContent = lazy(() => import('@/app/[locale]/apps/page'))
const ServicesContent = lazy(() => import('@/app/[locale]/services/page'))
import { AppsPanel } from '@/components/panels/AppsPanel'
import { ServicesPanel } from '@/components/panels/ServicesPanel'
export default function ObservabilityPage({ params }: { params: { locale: string } }) {
const t = useTranslations('nav')
@@ -35,7 +28,7 @@ export default function ObservabilityPage({ params }: { params: { locale: string
{
id: 'monitoring',
label: t('monitoring'),
content: <MonitoringPanel />, // Panel 元件,無雙重 AppLayout
content: <MonitoringPanel />,
},
{
id: 'apm',
@@ -50,12 +43,12 @@ export default function ObservabilityPage({ params }: { params: { locale: string
{
id: 'apps',
label: t('apps'),
content: <Suspense fallback={<LobsterLoading />}><AppsContent params={params} /></Suspense>,
content: <AppsPanel />,
},
{
id: 'services',
label: t('services'),
content: <Suspense fallback={<LobsterLoading />}><ServicesContent params={params} /></Suspense>,
content: <ServicesPanel />,
},
]

View File

@@ -3,35 +3,29 @@
/**
* 營運 (/operations) — Sprint 5 整合頁面
* 整合: 部署管理 + 工單 + 成本分析 + 行動日誌 + 計費
* 零假數據: 全部載入現有頁面內容
* 全部使用 Panel 元件 (無雙重 AppLayout)
* 建立時間: 2026-04-08 (台北時區)
* 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成
*/
import { lazy, Suspense } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { LobsterLoading } from '@/components/shared/lobster-loading'
const DeploymentsContent = lazy(() => import('@/app/[locale]/deployments/page'))
const TicketsContent = lazy(() => import('@/app/[locale]/tickets/page'))
const CostContent = lazy(() => import('@/app/[locale]/cost/page'))
const ActionLogsContent = lazy(() => import('@/app/[locale]/action-logs/page'))
const BillingContent = lazy(() => import('@/app/[locale]/billing/page'))
function Loading() {
return <LobsterLoading size="sm" />
}
import { DeploymentsPanel } from '@/components/panels/DeploymentsPanel'
import { TicketsPanel } from '@/components/panels/TicketsPanel'
import { CostPanel } from '@/components/panels/CostPanel'
import { ActionLogsPanel } from '@/components/panels/ActionLogsPanel'
import { BillingPanel } from '@/components/panels/BillingPanel'
export default function OperationsPage({ params }: { params: { locale: string } }) {
const t = useTranslations('nav')
const tabs: TabConfig[] = [
{ id: 'deployments', label: t('deployments'), content: <Suspense fallback={<Loading />}><DeploymentsContent params={params} /></Suspense> },
{ id: 'tickets', label: t('tickets'), content: <Suspense fallback={<Loading />}><TicketsContent params={params} /></Suspense> },
{ id: 'cost', label: t('cost'), content: <Suspense fallback={<Loading />}><CostContent params={params} /></Suspense> },
{ id: 'logs', label: t('actions'), content: <Suspense fallback={<Loading />}><ActionLogsContent params={params} /></Suspense> },
{ id: 'billing', label: t('billing'), content: <Suspense fallback={<Loading />}><BillingContent params={params} /></Suspense> },
{ id: 'deployments', label: t('deployments'), content: <DeploymentsPanel /> },
{ id: 'tickets', label: t('tickets'), content: <TicketsPanel /> },
{ id: 'cost', label: t('cost'), content: <CostPanel /> },
{ id: 'logs', label: t('actions'), content: <ActionLogsPanel /> },
{ id: 'billing', label: t('billing'), content: <BillingPanel /> },
]
return (

View File

@@ -0,0 +1,511 @@
'use client'
/**
* ActionLogsPanel — K8s 操作稽核日誌面板 (不含 AppLayout)
* ==========================================================
* Sprint 5: 從 /action-logs/page.tsx 抽取
* 供原始頁面和整合頁面 (/operations) 共用
*
* 版本: v1.1
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations, useLocale } from 'next-intl'
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'
// =============================================================================
// 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 ''
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 ''
}
return url
}
// =============================================================================
// StatCard
// =============================================================================
function StatCard({
icon: Icon,
label,
value,
subValue,
variant = 'default',
}: {
icon: typeof Activity
label: string
value: string | number
subValue?: string
variant?: 'default' | 'success' | 'warning'
}) {
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>
)
}
// =============================================================================
// ActionLogsPanel
// =============================================================================
export function ActionLogsPanel() {
const t = useTranslations()
const locale = useLocale()
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)
const abortControllerRef = useRef<AbortController | null>(null)
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) {
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)
}
}, [])
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) {
if (err instanceof Error && err.name === 'AbortError') {
return
}
console.error('[ActionLog] Stats fetch error:', err) // eslint-disable-line no-console
}
}, [])
useEffect(() => {
fetchLogs(1)
fetchStats()
return () => {
abortControllerRef.current?.abort()
}
}, [fetchLogs, fetchStats])
const handlePrevPage = () => {
if (page > 1) {
fetchLogs(page - 1)
}
}
const handleNextPage = () => {
if (page < totalPages) {
fetchLogs(page + 1)
}
}
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`
}
return (
<>
{/* 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>
</>
)
}

View File

@@ -0,0 +1,103 @@
'use client'
/**
* AppsPanel — 應用服務狀態面板 (不含 AppLayout)
* ================================================
* Sprint 5: 從 /apps/page.tsx 抽取
* 供原始頁面和整合頁面 (/observability) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
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',
}
export function AppsPanel() {
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 (
<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>
)
}

View File

@@ -0,0 +1,435 @@
'use client'
/**
* AutoRepairPanel — 自動修復面板 (不含 AppLayout)
* ==================================================
* Sprint 5: 從 /auto-repair/page.tsx 抽取
* 供原始頁面和整合頁面 (/automation) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
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
// =============================================================================
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',
}
// =============================================================================
// StatCard
// =============================================================================
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>
)
}
// =============================================================================
// IncidentEvalRow
// =============================================================================
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 不可用時靜默處理
} 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">
<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 && 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>
{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>
)}
{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>
)
}
// =============================================================================
// AutoRepairPanel
// =============================================================================
export function AutoRepairPanel() {
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 (
<>
{/* 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>
)}
{/* Disposition summary */}
{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>
</>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
/**
* BillingPanel — 系統操作使用量面板 (不含 AppLayout)
* ====================================================
* Sprint 5: 從 /billing/page.tsx 抽取
* 供原始頁面和整合頁面 (/operations) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface AuditStats {
total_executions: number
success_count: number
failure_count: number
success_rate: number
avg_duration_ms: number | null
last_24h_count: number
by_operation_type: Record<string, number>
by_namespace: Record<string, number>
}
export function BillingPanel() {
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 (
<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>
{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>
)}
{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>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
/**
* CostPanel — AI 執行效能統計面板 (不含 AppLayout)
* ==================================================
* Sprint 5: 從 /cost/page.tsx 抽取
* 供原始頁面和整合頁面 (/operations) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface AIPerformance {
total_proposals: number
executed_count: number
execution_rate: number
success_count: number
success_rate: number
avg_effectiveness: number | null
effectiveness_distribution: Record<string, number>
}
export function CostPanel() {
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 (
<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>
{data.effectiveness_distribution && Object.keys(data.effectiveness_distribution).length > 0 && (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
Effectiveness Distribution (15)
</div>
<div style={{ display: 'flex', gap: 0 }}>
{[1, 2, 3, 4, 5].map(score => {
const count = data.effectiveness_distribution[String(score)] ?? 0
const barColors = ['#cc2200', '#F59E0B', '#87867f', '#4A90D9', '#22C55E']
return (
<div key={score} style={{ flex: 1, padding: '12px 14px', borderRight: '0.5px solid #f0ede4', textAlign: 'center' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: barColors[score - 1], fontFamily: 'var(--font-body), monospace', marginBottom: 4 }}>{score}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{count}</div>
</div>
)
})}
</div>
</div>
)}
</>
) : (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
)}
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
/**
* DeploymentsPanel — K3s 部署狀態面板 (不含 AppLayout)
* =====================================================
* Sprint 5: 從 /deployments/page.tsx 抽取
* 供原始頁面和整合頁面 (/operations) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
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',
}
export function DeploymentsPanel() {
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 (
<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>
)
}

View File

@@ -0,0 +1,311 @@
'use client'
/**
* DriftPanel — 配置漂移偵測面板 (不含 AppLayout)
* ==================================================
* Sprint 5: 從 /drift/page.tsx 抽取
* 供原始頁面和整合頁面 (/automation) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
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>
)
}
// =============================================================================
// DriftPanel
// =============================================================================
export function DriftPanel() {
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)
await fetchReports()
} catch (e) {
setError(e instanceof Error ? e.message : 'Scan failed')
} finally {
setScanning(false)
}
}
return (
<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">
<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>
<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>
{report.interpretation && (
<p className="mt-2 text-[11px] text-neutral-500 pl-1 border-l-2 border-neutral-100">
{report.interpretation}
</p>
)}
<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>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
/**
* NeuralCommandPanel — 神經指揮中心面板 (不含 AppLayout)
* =========================================================
* Sprint 5: 從 /neural-command/page.tsx 抽取
* 供原始頁面和整合頁面 (/automation) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
BrainCircuit, ShieldCheck,
RefreshCw, Clock, Database,
Activity, Lock,
} 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'
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 },
]
export function NeuralCommandPanel() {
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 (
<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">
<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>
<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>
)
}

View File

@@ -0,0 +1,119 @@
'use client'
/**
* ServicesPanel — 服務目錄面板 (不含 AppLayout)
* ================================================
* Sprint 5: 從 /services/page.tsx 抽取
* 供原始頁面和整合頁面 (/observability) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
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'
}
export function ServicesPanel() {
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 (
<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')}
</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>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
/**
* TicketsPanel — 工單追蹤面板 (不含 AppLayout)
* ==============================================
* Sprint 5: 從 /tickets/page.tsx 抽取
* 供原始頁面和整合頁面 (/operations) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
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',
}
export function TicketsPanel() {
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 (
<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>
)
}

View File

@@ -15,3 +15,13 @@
export { MonitoringPanel } from './MonitoringPanel'
export { ApmPanel } from './ApmPanel'
export { ErrorsPanel } from './ErrorsPanel'
export { AppsPanel } from './AppsPanel'
export { ServicesPanel } from './ServicesPanel'
export { AutoRepairPanel } from './AutoRepairPanel'
export { NeuralCommandPanel } from './NeuralCommandPanel'
export { DriftPanel } from './DriftPanel'
export { DeploymentsPanel } from './DeploymentsPanel'
export { TicketsPanel } from './TicketsPanel'
export { CostPanel } from './CostPanel'
export { ActionLogsPanel } from './ActionLogsPanel'
export { BillingPanel } from './BillingPanel'