From 7934ade3a6768a9f718057208498711484c62bb4 Mon Sep 17 00:00:00 2001 From: OG T Date: Thu, 9 Apr 2026 11:05:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E5=85=A8=E9=83=A8=2013=20Pane?= =?UTF-8?q?l=20=E6=8A=BD=E5=8F=96=E5=AE=8C=E6=88=90=20+=20=E6=95=B4?= =?UTF-8?q?=E5=90=88=E9=A0=81=E9=9D=A2=E9=9B=99=E9=87=8D=20AppLayout=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/app/[locale]/automation/page.tsx | 23 +- .../src/app/[locale]/observability/page.tsx | 21 +- apps/web/src/app/[locale]/operations/page.tsx | 30 +- .../src/components/panels/ActionLogsPanel.tsx | 511 ++++++++++++++++++ apps/web/src/components/panels/AppsPanel.tsx | 103 ++++ .../src/components/panels/AutoRepairPanel.tsx | 435 +++++++++++++++ .../src/components/panels/BillingPanel.tsx | 111 ++++ apps/web/src/components/panels/CostPanel.tsx | 94 ++++ .../components/panels/DeploymentsPanel.tsx | 113 ++++ apps/web/src/components/panels/DriftPanel.tsx | 311 +++++++++++ .../components/panels/NeuralCommandPanel.tsx | 181 +++++++ .../src/components/panels/ServicesPanel.tsx | 119 ++++ .../src/components/panels/TicketsPanel.tsx | 120 ++++ apps/web/src/components/panels/index.ts | 10 + 14 files changed, 2135 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/components/panels/ActionLogsPanel.tsx create mode 100644 apps/web/src/components/panels/AppsPanel.tsx create mode 100644 apps/web/src/components/panels/AutoRepairPanel.tsx create mode 100644 apps/web/src/components/panels/BillingPanel.tsx create mode 100644 apps/web/src/components/panels/CostPanel.tsx create mode 100644 apps/web/src/components/panels/DeploymentsPanel.tsx create mode 100644 apps/web/src/components/panels/DriftPanel.tsx create mode 100644 apps/web/src/components/panels/NeuralCommandPanel.tsx create mode 100644 apps/web/src/components/panels/ServicesPanel.tsx create mode 100644 apps/web/src/components/panels/TicketsPanel.tsx diff --git a/apps/web/src/app/[locale]/automation/page.tsx b/apps/web/src/app/[locale]/automation/page.tsx index c9bfe493..6ba0d347 100644 --- a/apps/web/src/app/[locale]/automation/page.tsx +++ b/apps/web/src/app/[locale]/automation/page.tsx @@ -3,32 +3,25 @@ /** * 自動化 (/automation) — Sprint 5 整合頁面 * 整合: 自動修復 + 神經指揮 + Drift 偵測 - * 零假數據: 全部載入現有頁面內容 + * 全部使用 Panel 元件 (無雙重 AppLayout) * 建立時間: 2026-04-08 (台北時區) + * 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成 */ -import { lazy, Suspense } from 'react' import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' import { PageTabs, type TabConfig } from '@/components/layout/page-tabs' -import { LobsterLoading } from '@/components/shared/lobster-loading' - -const AutoRepairContent = lazy(() => import('@/app/[locale]/auto-repair/page')) -const NeuralCommandContent = lazy(() => import('@/app/[locale]/neural-command/page')) -const DriftContent = lazy(() => import('@/app/[locale]/drift/page')) - -// C3 修正: 用 LobsterLoading 取代硬編碼「載入中」 -function Loading() { - return -} +import { AutoRepairPanel } from '@/components/panels/AutoRepairPanel' +import { NeuralCommandPanel } from '@/components/panels/NeuralCommandPanel' +import { DriftPanel } from '@/components/panels/DriftPanel' export default function AutomationPage({ params }: { params: { locale: string } }) { const t = useTranslations('nav') const tabs: TabConfig[] = [ - { id: 'repair', label: t('autoRepair'), content: }> }, - { id: 'neural', label: t('neuralCommand'), content: }> }, - { id: 'drift', label: t('drift'), content: }> }, + { id: 'repair', label: t('autoRepair'), content: }, + { id: 'neural', label: t('neuralCommand'), content: }, + { id: 'drift', label: t('drift'), content: }, ] return ( diff --git a/apps/web/src/app/[locale]/observability/page.tsx b/apps/web/src/app/[locale]/observability/page.tsx index b12efdcb..a2f792be 100644 --- a/apps/web/src/app/[locale]/observability/page.tsx +++ b/apps/web/src/app/[locale]/observability/page.tsx @@ -4,29 +4,22 @@ * 可觀測性 (/observability) — Sprint 5 整合頁面 * ================================================ * 整合: 服務監控 + APM + 錯誤追蹤 + 應用 + 服務目錄 - * - * Tab 1 (monitoring) 使用 Panel 元件 (無雙重 AppLayout) - * Tab 2-5 暫時用 lazy import (未來逐步抽取 Panel) + * 全部使用 Panel 元件 (無雙重 AppLayout) * * 零假數據: 全部串接真實 API * * 建立時間: 2026-04-08 (台北時區) - * 更新時間: 2026-04-09 — Panel 抽取 (monitoring) + * 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成 */ -import { lazy, Suspense } from 'react' import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' import { PageTabs, type TabConfig } from '@/components/layout/page-tabs' import { MonitoringPanel } from '@/components/panels/MonitoringPanel' import { ApmPanel } from '@/components/panels/ApmPanel' import { ErrorsPanel } from '@/components/panels/ErrorsPanel' -import { LobsterLoading } from '@/components/shared/lobster-loading' - -// Tab 4-5: 暫時 lazy import (未來抽取 Panel) -const ErrorsContent = lazy(() => import('@/app/[locale]/errors/page')) -const AppsContent = lazy(() => import('@/app/[locale]/apps/page')) -const ServicesContent = lazy(() => import('@/app/[locale]/services/page')) +import { AppsPanel } from '@/components/panels/AppsPanel' +import { ServicesPanel } from '@/components/panels/ServicesPanel' export default function ObservabilityPage({ params }: { params: { locale: string } }) { const t = useTranslations('nav') @@ -35,7 +28,7 @@ export default function ObservabilityPage({ params }: { params: { locale: string { id: 'monitoring', label: t('monitoring'), - content: , // Panel 元件,無雙重 AppLayout + content: , }, { id: 'apm', @@ -50,12 +43,12 @@ export default function ObservabilityPage({ params }: { params: { locale: string { id: 'apps', label: t('apps'), - content: }>, + content: , }, { id: 'services', label: t('services'), - content: }>, + content: , }, ] diff --git a/apps/web/src/app/[locale]/operations/page.tsx b/apps/web/src/app/[locale]/operations/page.tsx index bdf4ca0b..d104ccf1 100644 --- a/apps/web/src/app/[locale]/operations/page.tsx +++ b/apps/web/src/app/[locale]/operations/page.tsx @@ -3,35 +3,29 @@ /** * 營運 (/operations) — Sprint 5 整合頁面 * 整合: 部署管理 + 工單 + 成本分析 + 行動日誌 + 計費 - * 零假數據: 全部載入現有頁面內容 + * 全部使用 Panel 元件 (無雙重 AppLayout) * 建立時間: 2026-04-08 (台北時區) + * 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成 */ -import { lazy, Suspense } from 'react' import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' import { PageTabs, type TabConfig } from '@/components/layout/page-tabs' -import { LobsterLoading } from '@/components/shared/lobster-loading' - -const DeploymentsContent = lazy(() => import('@/app/[locale]/deployments/page')) -const TicketsContent = lazy(() => import('@/app/[locale]/tickets/page')) -const CostContent = lazy(() => import('@/app/[locale]/cost/page')) -const ActionLogsContent = lazy(() => import('@/app/[locale]/action-logs/page')) -const BillingContent = lazy(() => import('@/app/[locale]/billing/page')) - -function Loading() { - return -} +import { DeploymentsPanel } from '@/components/panels/DeploymentsPanel' +import { TicketsPanel } from '@/components/panels/TicketsPanel' +import { CostPanel } from '@/components/panels/CostPanel' +import { ActionLogsPanel } from '@/components/panels/ActionLogsPanel' +import { BillingPanel } from '@/components/panels/BillingPanel' export default function OperationsPage({ params }: { params: { locale: string } }) { const t = useTranslations('nav') const tabs: TabConfig[] = [ - { id: 'deployments', label: t('deployments'), content: }> }, - { id: 'tickets', label: t('tickets'), content: }> }, - { id: 'cost', label: t('cost'), content: }> }, - { id: 'logs', label: t('actions'), content: }> }, - { id: 'billing', label: t('billing'), content: }> }, + { id: 'deployments', label: t('deployments'), content: }, + { id: 'tickets', label: t('tickets'), content: }, + { id: 'cost', label: t('cost'), content: }, + { id: 'logs', label: t('actions'), content: }, + { id: 'billing', label: t('billing'), content: }, ] return ( diff --git a/apps/web/src/components/panels/ActionLogsPanel.tsx b/apps/web/src/components/panels/ActionLogsPanel.tsx new file mode 100644 index 00000000..ff22c637 --- /dev/null +++ b/apps/web/src/components/panels/ActionLogsPanel.tsx @@ -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 | 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 + by_namespace: Record + 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 ( +
+
+
+ +
+
+

+ {label} +

+

{value}

+ {subValue && ( +

+ {subValue} +

+ )} +
+
+
+ ) +} + +// ============================================================================= +// ActionLogsPanel +// ============================================================================= + +export function ActionLogsPanel() { + const t = useTranslations() + const locale = useLocale() + + const [logs, setLogs] = useState([]) + const [stats, setStats] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [totalCount, setTotalCount] = useState(0) + + const abortControllerRef = useRef(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 */} +
+

+ {t('actionLog.title')} +

+

+ {t('actionLog.subtitle')} +

+
+ + {/* Stats Overview */} + {stats && ( +
+ + + + +
+ )} + + {/* Main Content */} + + {/* Toolbar */} +
+
+ {totalCount > 0 + ? `${totalCount} ${t('actionLog.columns.operation').toLowerCase()}s` + : ''} +
+ +
+ + {/* Error State */} + {error && ( +
+ + + {t('actionLog.fetchError')}: {error} + +
+ )} + + {/* Loading State */} + {isLoading && logs.length === 0 && ( +
+ + + {t('actionLog.loading')} + +
+ )} + + {/* Empty State */} + {!isLoading && logs.length === 0 && !error && ( +
+ +

+ {t('actionLog.noLogs')} +

+
+ )} + + {/* Logs Table */} + {logs.length > 0 && ( +
+ + + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + + ))} + +
+ {t('actionLog.columns.time')} + + {t('actionLog.columns.operation')} + + {t('actionLog.columns.target')} + + {t('actionLog.columns.namespace')} + + {t('actionLog.columns.status')} + + {t('actionLog.columns.duration')} + + {t('actionLog.columns.executor')} +
+ {formatDate(log.created_at)} + + + {t(`actionLog.operations.${log.operation_type}` as never) || + log.operation_type} + + + {log.target_resource} + + {log.namespace} + + {log.success ? ( + + + + {t('actionLog.status.success')} + + + ) : ( + + + + {t('actionLog.status.failure')} + + + )} + + {formatDuration(log.execution_duration_ms)} + + {log.executed_by} +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + {t('actionLog.pagination.page', { + current: page, + total: totalPages, + })} + +
+ + +
+
+ )} +
+ + {/* Footer */} +
+
+

+ {t('footer.copyright')} +

+

+ {t('footer.poweredBy')} v1.0.0 +

+
+
+ + ) +} diff --git a/apps/web/src/components/panels/AppsPanel.tsx b/apps/web/src/components/panels/AppsPanel.tsx new file mode 100644 index 00000000..262c8ac7 --- /dev/null +++ b/apps/web/src/components/panels/AppsPanel.tsx @@ -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 = { + up: '#22C55E', + healthy: '#22C55E', + down: '#cc2200', + degraded: '#F59E0B', + unreachable: '#87867f', +} + +export function AppsPanel() { + const t = useTranslations('apps') + const [hosts, setHosts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+
+
+ + {t('title')} ({loading ? '...' : allServices.length}) +
+ {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : allServices.length === 0 ? ( +
{t('noApps')}
+ ) : ( + + + + {[t('service'), t('host'), t('port'), t('latency'), t('status')].map(col => ( + + ))} + + + + {allServices.map((s, i) => ( + + + + + + + + ))} + +
{col}
{s.name}{s.hostName} ({s.hostIp}){s.port ?? '—'}{s.latency_ms != null ? `${s.latency_ms.toFixed(0)}ms` : '—'} + + + {s.status} + +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/panels/AutoRepairPanel.tsx b/apps/web/src/components/panels/AutoRepairPanel.tsx new file mode 100644 index 00000000..61868df4 --- /dev/null +++ b/apps/web/src/components/panels/AutoRepairPanel.tsx @@ -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 = { + 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 ( +
+

{label}

+

+ {value} +

+ {sub &&

{sub}

} +
+ ) +} + +// ============================================================================= +// IncidentEvalRow +// ============================================================================= + +function IncidentEvalRow({ + incidentId, severity, +}: { incidentId: string; severity: string }) { + const t = useTranslations('autoRepair') + const [eval_, setEval] = useState(null) + const [loading, setLoading] = useState(false) + const [executing, setExecuting] = useState(false) + const [result, setResult] = useState(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 ( +
+
setExpanded(v => !v)} + > + {severity} + {incidentId} + + {loading && } + {!loading && eval_ && ( + eval_.can_auto_repair + ? {t('canAutoRepair')} + : {t('notEligibleShort')} + )} + {expanded ? : } +
+ + {expanded && eval_ && ( +
+
+
+ Playbook +

{eval_.playbook_name ?? '—'}

+
+
+ {t('riskLevel')} +

+ + {eval_.risk_level} + +

+
+ {eval_.success_rate != null && ( +
+ {t('successRate')} +

{(eval_.success_rate * 100).toFixed(1)}%

+
+ )} + {eval_.total_executions != null && ( +
+ {t('execCount')} +

{eval_.total_executions}

+
+ )} +
+ +
+ {t('decisionReason')} +

{eval_.reason}

+
+ + {result && ( +
+
+ {result.success + ? + : } + + {result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error })} + +
+ {result.executed_steps.length > 0 && ( +
    + {result.executed_steps.map((step, i) => ( +
  • + + {step} +
  • + ))} +
+ )} +
+ )} + + {eval_.can_auto_repair && !result && ( + + )} +
+ )} +
+ ) +} + +// ============================================================================= +// AutoRepairPanel +// ============================================================================= + +export function AutoRepairPanel() { + const t = useTranslations('autoRepair') + const tNav = useTranslations('nav') + const tCommon = useTranslations('common') + + const [stats, setStats] = useState(null) + const [statsLoading, setStatsLoading] = useState(true) + const [statsError, setStatsError] = useState(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(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 */} +
+
+

+ + {tNav('autoRepair')} +

+

+ {t('subtitle')} +

+
+ +
+ + {/* Error */} + {statsError && ( +
+ + {statsError} +
+ )} + + {/* Stats */} + {stats && ( +
+ + + + +
+ )} + + {/* Disposition summary */} + {disposition && disposition.total > 0 && ( +
+
+

{t('dispositionAuto')}

+

{disposition.auto_repair}

+
+
+

{t('dispositionHuman')}

+

{disposition.human_approved}

+
+
+

{t('dispositionManual')}

+

{disposition.manual_resolved}

+
+
+

{t('dispositionCold')}

+

{disposition.cold_start_trust}

+
+
+ )} + + {/* Eligible indicator */} + {stats && ( +
+ {stats.auto_repair_eligible + ? + : } +
+

+ {stats.auto_repair_eligible ? t('ready') : t('notReady')} +

+

+ {stats.auto_repair_eligible + ? t('readyDesc', { count: stats.high_quality_playbooks }) + : t('notReadyDesc')} +

+
+
+ )} + + {/* Incident evaluation list */} +
+

+ {t('incidentEval')} +

+ + {incidentsLoading && ( +
+ +
+ )} + + {!incidentsLoading && eligibleIncidents.length === 0 && ( +
+ +

{t('noEligible')}

+
+ )} + +
+ {eligibleIncidents.map(incident => ( + + ))} +
+
+ + ) +} diff --git a/apps/web/src/components/panels/BillingPanel.tsx b/apps/web/src/components/panels/BillingPanel.tsx new file mode 100644 index 00000000..15b261ff --- /dev/null +++ b/apps/web/src/components/panels/BillingPanel.tsx @@ -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 + by_namespace: Record +} + +export function BillingPanel() { + const t = useTranslations('billing') + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + fetch(`${API_BASE}/api/v1/audit-logs/stats`) + .then(r => r.json()) + .then((d: AuditStats) => { setStats(d); setLoading(false) }) + .catch(err => { setError(String(err)); setLoading(false) }) + }, []) + + const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ + {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : stats ? ( + <> +
+ {[ + { label: t('totalUsage'), value: stats.total_executions }, + { label: t('last24h'), value: stats.last_24h_count }, + { label: t('successRate'), value: `${(stats.success_rate * 100).toFixed(1)}%` }, + { label: t('avgDuration'), value: stats.avg_duration_ms ? `${stats.avg_duration_ms.toFixed(0)}ms` : '—' }, + ].map(card => ( +
+
{card.label}
+
{card.value}
+
+ ))} +
+ + {Object.keys(stats.by_operation_type).length > 0 && ( +
+
+ By Operation Type +
+ + + {Object.entries(stats.by_operation_type).sort(([, a], [, b]) => b - a).map(([op, count]) => ( + + + + + ))} + +
{op}{count}
+
+ )} + + {Object.keys(stats.by_namespace).length > 0 && ( +
+
+ By Namespace +
+ + + {Object.entries(stats.by_namespace).sort(([, a], [, b]) => b - a).map(([ns, count]) => ( + + + + + ))} + +
{ns}{count}
+
+ )} + + ) : ( +
{t('noData')}
+ )} +
+ ) +} diff --git a/apps/web/src/components/panels/CostPanel.tsx b/apps/web/src/components/panels/CostPanel.tsx new file mode 100644 index 00000000..02fab0a8 --- /dev/null +++ b/apps/web/src/components/panels/CostPanel.tsx @@ -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 +} + +export function CostPanel() { + const t = useTranslations('cost') + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + fetch(`${API_BASE}/api/v1/stats/ai-performance?days=30`) + .then(r => r.json()) + .then((d: AIPerformance) => { setData(d); setLoading(false) }) + .catch(err => { setError(String(err)); setLoading(false) }) + }, []) + + const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ + {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : data ? ( + <> +
+ {[ + { label: t('totalProposals'), value: data.total_proposals }, + { label: t('executionRate'), value: `${(data.execution_rate).toFixed(1)}%` }, + { label: t('successRate'), value: `${(data.success_rate).toFixed(1)}%` }, + { label: t('avgEffectiveness'), value: data.avg_effectiveness ? data.avg_effectiveness.toFixed(2) : '—' }, + ].map(card => ( +
+
{card.label}
+
{card.value}
+
+ ))} +
+ + {data.effectiveness_distribution && Object.keys(data.effectiveness_distribution).length > 0 && ( +
+
+ Effectiveness Distribution (1–5) +
+
+ {[1, 2, 3, 4, 5].map(score => { + const count = data.effectiveness_distribution[String(score)] ?? 0 + const barColors = ['#cc2200', '#F59E0B', '#87867f', '#4A90D9', '#22C55E'] + return ( +
+
★{score}
+
{count}
+
+ ) + })} +
+
+ )} + + ) : ( +
{t('noData')}
+ )} +
+ ) +} diff --git a/apps/web/src/components/panels/DeploymentsPanel.tsx b/apps/web/src/components/panels/DeploymentsPanel.tsx new file mode 100644 index 00000000..594eca3f --- /dev/null +++ b/apps/web/src/components/panels/DeploymentsPanel.tsx @@ -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 = { + up: '#22C55E', + healthy: '#22C55E', + down: '#cc2200', + degraded: '#F59E0B', + unreachable: '#87867f', +} + +export function DeploymentsPanel() { + const t = useTranslations('deployments') + const [hosts, setHosts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : displayHosts.length === 0 ? ( +
{t('noDeployments')}
+ ) : ( + displayHosts.map(host => ( +
+
+
+ + {host.name} + {host.ip} +
+ + {host.last_check ? new Date(host.last_check).toLocaleTimeString('zh-TW', { timeZone: 'Asia/Taipei' }) : '—'} + +
+ + + + {[t('service'), t('port'), t('latency'), t('status')].map(col => ( + + ))} + + + + {host.services.length === 0 ? ( + + ) : host.services.map((s, i) => ( + + + + + + + ))} + +
{col}
{t('noDeployments')}
{s.name}{s.port ?? '—'}{s.latency_ms != null ? `${s.latency_ms.toFixed(0)}ms` : '—'} + + + {s.status} + +
+
+ )) + )} +
+ ) +} diff --git a/apps/web/src/components/panels/DriftPanel.tsx b/apps/web/src/components/panels/DriftPanel.tsx new file mode 100644 index 00000000..fb5d8c80 --- /dev/null +++ b/apps/web/src/components/panels/DriftPanel.tsx @@ -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 ( + + + {t('noDrift')} + + ) + } + return ( +
+ {high > 0 && ( + + + {t('highCount')} {high} + + )} + {medium > 0 && ( + + + {t('mediumCount')} {medium} + + )} + {info > 0 && ( + + {t('infoCount')} {info} + + )} +
+ ) +} + +function StatusBadge({ status, t }: { status: DriftReport['status']; t: (k: string) => string }) { + const styles: Record = { + 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 ( + + {t(status)} + + ) +} + +// ============================================================================= +// DriftPanel +// ============================================================================= + +export function DriftPanel() { + const t = useTranslations('drift') + const [reports, setReports] = useState([]) + const [loading, setLoading] = useState(true) + const [scanning, setScanning] = useState(false) + const [scanResult, setScanResult] = useState(null) + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+
+ +
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+
+
+ + +
+
+ + {/* Scan Result Banner */} + {scanResult && ( +
+
+ {scanResult.has_critical_drift + ? + : + } + {scanResult.summary} + {(scanResult.high_count > 0 || scanResult.medium_count > 0) && ( + + — {t('highCount')} {scanResult.high_count}, {t('mediumCount')} {scanResult.medium_count} + + )} +
+ {scanResult.interpretation && ( +

+ {scanResult.interpretation} +

+ )} +
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Content */} +
+ {loading && reports.length === 0 ? ( +
+ + {t('loading')} +
+ ) : reports.length === 0 ? ( +
+ +

{t('noReports')}

+

{t('noReportsHint')}

+
+ ) : ( +
+ {reports.map((report) => ( +
+
+
+ + {report.report_id.slice(0, 8)} + + +
+
+ + + + {fmtTime(report.scanned_at)} + +
+
+ {report.interpretation && ( +

+ {report.interpretation} +

+ )} +
+ {t('namespace')}: {report.namespace} + {t('triggeredBy')}: {report.triggered_by} +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/panels/NeuralCommandPanel.tsx b/apps/web/src/components/panels/NeuralCommandPanel.tsx new file mode 100644 index 00000000..76d378dc --- /dev/null +++ b/apps/web/src/components/panels/NeuralCommandPanel.tsx @@ -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('preflight') + const [stats, setStats] = useState(null) + const [playbooks, setPlaybooks] = useState([]) + const [history, setHistory] = useState([]) + const [loading, setLoading] = useState(true) + const [lastRefresh, setLastRefresh] = useState(new Date()) + const [pendingApprovals, setPendingApprovals] = useState(0) + const [pendingApprovalList, setPendingApprovalList] = useState([]) + const [activeIncidents, setActiveIncidents] = useState([]) + 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 ( +
+ {/* Page header */} +
+
+
+ +
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ +
+
+ + + OpenClaw + + + + NemoTron + +
+ +
+ + {t('lastRefresh', { time: lastRefresh.toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) })} + +
+
+
+ + {/* Tabs */} +
+ {TABS.map(({ id, labelKey, Icon }) => ( + + ))} +
+ + {/* Tab content */} +
+ {activeTab === 'preflight' && ( + + )} + {activeTab === 'live' && ( + + )} + {activeTab === 'stats' && ( + + )} + {activeTab === 'approval' && ( + + )} +
+
+ ) +} diff --git a/apps/web/src/components/panels/ServicesPanel.tsx b/apps/web/src/components/panels/ServicesPanel.tsx new file mode 100644 index 00000000..1b008752 --- /dev/null +++ b/apps/web/src/components/panels/ServicesPanel.tsx @@ -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([]) + 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 ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+ + {t('title')} +
+ + {loading && ( +
+ {tc('loading')} +
+ )} + + {!loading && error && ( +
+ {t('fetchError')} +
+ )} + + {!loading && !error && services.length === 0 && ( +
+ {t('noServices')} +
+ )} + + {!loading && !error && services.length > 0 && ( + + + + {[t('name'), t('host'), t('status'), t('cpu'), t('ram')].map(col => ( + + ))} + + + + {services.map((s, i) => ( + + + + + + + + ))} + +
{col}
{s.name}{s.host} + + + {s.status} + + {s.cpu != null ? `${s.cpu}%` : '--'}{s.ram != null ? `${s.ram}%` : '--'}
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/panels/TicketsPanel.tsx b/apps/web/src/components/panels/TicketsPanel.tsx new file mode 100644 index 00000000..1ec2dbbd --- /dev/null +++ b/apps/web/src/components/panels/TicketsPanel.tsx @@ -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 = { + P0: '#cc2200', + P1: '#F59E0B', + P2: '#4A90D9', + P3: '#22C55E', +} + +const STATUS_COLOR: Record = { + open: '#cc2200', + in_progress: '#F59E0B', + resolved: '#22C55E', + closed: '#87867f', +} + +export function TicketsPanel() { + const t = useTranslations('tickets') + const [incidents, setIncidents] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+
+
+ + {t('title')} ({loading ? '...' : total}) +
+ {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error')}
+ ) : incidents.length === 0 ? ( +
{t('noTickets')}
+ ) : ( + + + + {[t('id'), t('title_col'), t('priority'), t('status'), t('createdAt')].map(col => ( + + ))} + + + + {incidents.map((inc) => ( + + + + + + + + ))} + +
{col}
{inc.id.slice(0, 8)} +
{inc.title}
+ {inc.affected_service && ( +
{inc.affected_service}
+ )} +
+ + {inc.severity} + + + + {inc.status} + + + {new Date(inc.created_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/panels/index.ts b/apps/web/src/components/panels/index.ts index 81b1f9dc..a1e2e667 100644 --- a/apps/web/src/components/panels/index.ts +++ b/apps/web/src/components/panels/index.ts @@ -15,3 +15,13 @@ export { MonitoringPanel } from './MonitoringPanel' export { ApmPanel } from './ApmPanel' export { ErrorsPanel } from './ErrorsPanel' +export { AppsPanel } from './AppsPanel' +export { ServicesPanel } from './ServicesPanel' +export { AutoRepairPanel } from './AutoRepairPanel' +export { NeuralCommandPanel } from './NeuralCommandPanel' +export { DriftPanel } from './DriftPanel' +export { DeploymentsPanel } from './DeploymentsPanel' +export { TicketsPanel } from './TicketsPanel' +export { CostPanel } from './CostPanel' +export { ActionLogsPanel } from './ActionLogsPanel' +export { BillingPanel } from './BillingPanel'