refactor(web): 全部 13 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
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:
@@ -3,32 +3,25 @@
|
|||||||
/**
|
/**
|
||||||
* 自動化 (/automation) — Sprint 5 整合頁面
|
* 自動化 (/automation) — Sprint 5 整合頁面
|
||||||
* 整合: 自動修復 + 神經指揮 + Drift 偵測
|
* 整合: 自動修復 + 神經指揮 + Drift 偵測
|
||||||
* 零假數據: 全部載入現有頁面內容
|
* 全部使用 Panel 元件 (無雙重 AppLayout)
|
||||||
* 建立時間: 2026-04-08 (台北時區)
|
* 建立時間: 2026-04-08 (台北時區)
|
||||||
|
* 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy, Suspense } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { AppLayout } from '@/components/layout'
|
import { AppLayout } from '@/components/layout'
|
||||||
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
|
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
|
||||||
import { LobsterLoading } from '@/components/shared/lobster-loading'
|
import { AutoRepairPanel } from '@/components/panels/AutoRepairPanel'
|
||||||
|
import { NeuralCommandPanel } from '@/components/panels/NeuralCommandPanel'
|
||||||
const AutoRepairContent = lazy(() => import('@/app/[locale]/auto-repair/page'))
|
import { DriftPanel } from '@/components/panels/DriftPanel'
|
||||||
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" />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AutomationPage({ params }: { params: { locale: string } }) {
|
export default function AutomationPage({ params }: { params: { locale: string } }) {
|
||||||
const t = useTranslations('nav')
|
const t = useTranslations('nav')
|
||||||
|
|
||||||
const tabs: TabConfig[] = [
|
const tabs: TabConfig[] = [
|
||||||
{ id: 'repair', label: t('autoRepair'), content: <Suspense fallback={<Loading />}><AutoRepairContent params={params} /></Suspense> },
|
{ id: 'repair', label: t('autoRepair'), content: <AutoRepairPanel /> },
|
||||||
{ id: 'neural', label: t('neuralCommand'), content: <Suspense fallback={<Loading />}><NeuralCommandContent params={params} /></Suspense> },
|
{ id: 'neural', label: t('neuralCommand'), content: <NeuralCommandPanel /> },
|
||||||
{ id: 'drift', label: t('drift'), content: <Suspense fallback={<Loading />}><DriftContent params={params} /></Suspense> },
|
{ id: 'drift', label: t('drift'), content: <DriftPanel /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,29 +4,22 @@
|
|||||||
* 可觀測性 (/observability) — Sprint 5 整合頁面
|
* 可觀測性 (/observability) — Sprint 5 整合頁面
|
||||||
* ================================================
|
* ================================================
|
||||||
* 整合: 服務監控 + APM + 錯誤追蹤 + 應用 + 服務目錄
|
* 整合: 服務監控 + APM + 錯誤追蹤 + 應用 + 服務目錄
|
||||||
*
|
* 全部使用 Panel 元件 (無雙重 AppLayout)
|
||||||
* Tab 1 (monitoring) 使用 Panel 元件 (無雙重 AppLayout)
|
|
||||||
* Tab 2-5 暫時用 lazy import (未來逐步抽取 Panel)
|
|
||||||
*
|
*
|
||||||
* 零假數據: 全部串接真實 API
|
* 零假數據: 全部串接真實 API
|
||||||
*
|
*
|
||||||
* 建立時間: 2026-04-08 (台北時區)
|
* 建立時間: 2026-04-08 (台北時區)
|
||||||
* 更新時間: 2026-04-09 — Panel 抽取 (monitoring)
|
* 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy, Suspense } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { AppLayout } from '@/components/layout'
|
import { AppLayout } from '@/components/layout'
|
||||||
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
|
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
|
||||||
import { MonitoringPanel } from '@/components/panels/MonitoringPanel'
|
import { MonitoringPanel } from '@/components/panels/MonitoringPanel'
|
||||||
import { ApmPanel } from '@/components/panels/ApmPanel'
|
import { ApmPanel } from '@/components/panels/ApmPanel'
|
||||||
import { ErrorsPanel } from '@/components/panels/ErrorsPanel'
|
import { ErrorsPanel } from '@/components/panels/ErrorsPanel'
|
||||||
import { LobsterLoading } from '@/components/shared/lobster-loading'
|
import { AppsPanel } from '@/components/panels/AppsPanel'
|
||||||
|
import { ServicesPanel } from '@/components/panels/ServicesPanel'
|
||||||
// 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'))
|
|
||||||
|
|
||||||
export default function ObservabilityPage({ params }: { params: { locale: string } }) {
|
export default function ObservabilityPage({ params }: { params: { locale: string } }) {
|
||||||
const t = useTranslations('nav')
|
const t = useTranslations('nav')
|
||||||
@@ -35,7 +28,7 @@ export default function ObservabilityPage({ params }: { params: { locale: string
|
|||||||
{
|
{
|
||||||
id: 'monitoring',
|
id: 'monitoring',
|
||||||
label: t('monitoring'),
|
label: t('monitoring'),
|
||||||
content: <MonitoringPanel />, // Panel 元件,無雙重 AppLayout
|
content: <MonitoringPanel />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apm',
|
id: 'apm',
|
||||||
@@ -50,12 +43,12 @@ export default function ObservabilityPage({ params }: { params: { locale: string
|
|||||||
{
|
{
|
||||||
id: 'apps',
|
id: 'apps',
|
||||||
label: t('apps'),
|
label: t('apps'),
|
||||||
content: <Suspense fallback={<LobsterLoading />}><AppsContent params={params} /></Suspense>,
|
content: <AppsPanel />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'services',
|
id: 'services',
|
||||||
label: t('services'),
|
label: t('services'),
|
||||||
content: <Suspense fallback={<LobsterLoading />}><ServicesContent params={params} /></Suspense>,
|
content: <ServicesPanel />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,35 +3,29 @@
|
|||||||
/**
|
/**
|
||||||
* 營運 (/operations) — Sprint 5 整合頁面
|
* 營運 (/operations) — Sprint 5 整合頁面
|
||||||
* 整合: 部署管理 + 工單 + 成本分析 + 行動日誌 + 計費
|
* 整合: 部署管理 + 工單 + 成本分析 + 行動日誌 + 計費
|
||||||
* 零假數據: 全部載入現有頁面內容
|
* 全部使用 Panel 元件 (無雙重 AppLayout)
|
||||||
* 建立時間: 2026-04-08 (台北時區)
|
* 建立時間: 2026-04-08 (台北時區)
|
||||||
|
* 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy, Suspense } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { AppLayout } from '@/components/layout'
|
import { AppLayout } from '@/components/layout'
|
||||||
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
|
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
|
||||||
import { LobsterLoading } from '@/components/shared/lobster-loading'
|
import { DeploymentsPanel } from '@/components/panels/DeploymentsPanel'
|
||||||
|
import { TicketsPanel } from '@/components/panels/TicketsPanel'
|
||||||
const DeploymentsContent = lazy(() => import('@/app/[locale]/deployments/page'))
|
import { CostPanel } from '@/components/panels/CostPanel'
|
||||||
const TicketsContent = lazy(() => import('@/app/[locale]/tickets/page'))
|
import { ActionLogsPanel } from '@/components/panels/ActionLogsPanel'
|
||||||
const CostContent = lazy(() => import('@/app/[locale]/cost/page'))
|
import { BillingPanel } from '@/components/panels/BillingPanel'
|
||||||
const ActionLogsContent = lazy(() => import('@/app/[locale]/action-logs/page'))
|
|
||||||
const BillingContent = lazy(() => import('@/app/[locale]/billing/page'))
|
|
||||||
|
|
||||||
function Loading() {
|
|
||||||
return <LobsterLoading size="sm" />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OperationsPage({ params }: { params: { locale: string } }) {
|
export default function OperationsPage({ params }: { params: { locale: string } }) {
|
||||||
const t = useTranslations('nav')
|
const t = useTranslations('nav')
|
||||||
|
|
||||||
const tabs: TabConfig[] = [
|
const tabs: TabConfig[] = [
|
||||||
{ id: 'deployments', label: t('deployments'), content: <Suspense fallback={<Loading />}><DeploymentsContent params={params} /></Suspense> },
|
{ id: 'deployments', label: t('deployments'), content: <DeploymentsPanel /> },
|
||||||
{ id: 'tickets', label: t('tickets'), content: <Suspense fallback={<Loading />}><TicketsContent params={params} /></Suspense> },
|
{ id: 'tickets', label: t('tickets'), content: <TicketsPanel /> },
|
||||||
{ id: 'cost', label: t('cost'), content: <Suspense fallback={<Loading />}><CostContent params={params} /></Suspense> },
|
{ id: 'cost', label: t('cost'), content: <CostPanel /> },
|
||||||
{ id: 'logs', label: t('actions'), content: <Suspense fallback={<Loading />}><ActionLogsContent params={params} /></Suspense> },
|
{ id: 'logs', label: t('actions'), content: <ActionLogsPanel /> },
|
||||||
{ id: 'billing', label: t('billing'), content: <Suspense fallback={<Loading />}><BillingContent params={params} /></Suspense> },
|
{ id: 'billing', label: t('billing'), content: <BillingPanel /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
511
apps/web/src/components/panels/ActionLogsPanel.tsx
Normal file
511
apps/web/src/components/panels/ActionLogsPanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
apps/web/src/components/panels/AppsPanel.tsx
Normal file
103
apps/web/src/components/panels/AppsPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
435
apps/web/src/components/panels/AutoRepairPanel.tsx
Normal file
435
apps/web/src/components/panels/AutoRepairPanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
apps/web/src/components/panels/BillingPanel.tsx
Normal file
111
apps/web/src/components/panels/BillingPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
apps/web/src/components/panels/CostPanel.tsx
Normal file
94
apps/web/src/components/panels/CostPanel.tsx
Normal 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 (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>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
apps/web/src/components/panels/DeploymentsPanel.tsx
Normal file
113
apps/web/src/components/panels/DeploymentsPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
311
apps/web/src/components/panels/DriftPanel.tsx
Normal file
311
apps/web/src/components/panels/DriftPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
181
apps/web/src/components/panels/NeuralCommandPanel.tsx
Normal file
181
apps/web/src/components/panels/NeuralCommandPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
apps/web/src/components/panels/ServicesPanel.tsx
Normal file
119
apps/web/src/components/panels/ServicesPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
apps/web/src/components/panels/TicketsPanel.tsx
Normal file
120
apps/web/src/components/panels/TicketsPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,3 +15,13 @@
|
|||||||
export { MonitoringPanel } from './MonitoringPanel'
|
export { MonitoringPanel } from './MonitoringPanel'
|
||||||
export { ApmPanel } from './ApmPanel'
|
export { ApmPanel } from './ApmPanel'
|
||||||
export { ErrorsPanel } from './ErrorsPanel'
|
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'
|
||||||
|
|||||||
Reference in New Issue
Block a user