From 770667eed43eb7dd31b21cd44d236d7c0f3871ee Mon Sep 17 00:00:00 2001 From: OG T Date: Thu, 9 Apr 2026 10:40:07 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20MonitoringPanel=20=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=20=E2=80=94=20=E8=A7=A3=E6=B1=BA=20/observability=20?= =?UTF-8?q?=E9=9B=99=E9=87=8D=20AppLayout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/[locale]/monitoring/page.tsx | 258 +----------------- .../src/app/[locale]/observability/page.tsx | 60 ++-- .../src/components/panels/MonitoringPanel.tsx | 230 ++++++++++++++++ 3 files changed, 253 insertions(+), 295 deletions(-) create mode 100644 apps/web/src/components/panels/MonitoringPanel.tsx diff --git a/apps/web/src/app/[locale]/monitoring/page.tsx b/apps/web/src/app/[locale]/monitoring/page.tsx index 51a83430..d959a319 100644 --- a/apps/web/src/app/[locale]/monitoring/page.tsx +++ b/apps/web/src/app/[locale]/monitoring/page.tsx @@ -3,267 +3,21 @@ /** * Monitoring Page - 服務監控 * =========================== - * 黃金指標 (Gold Metrics) + 四主機狀態 + 服務健康 + * Sprint 5: 內容抽取到 MonitoringPanel,此頁面只加 AppLayout wrapper + * 獨立訪問 /monitoring 時使用 + * 整合頁面 /observability 直接 import MonitoringPanel (不含 AppLayout) * * @created 2026-04-01 ogt - * @updated 2026-04-02 Claude Code — QA 修復: i18n + useHosts + NaN + * @updated 2026-04-09 Claude Code — Sprint 5 Panel 抽取 */ -import { useState, useEffect, useCallback, useRef } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' -import { useGlobalPulseMetrics } from '@/hooks/useGlobalPulseMetrics' -import { useHosts } from '@/stores/dashboard.store' -import { GlobalPulseChart } from '@/components/charts/global-pulse-chart' -import { HostGrid } from '@/components/infra/host-grid' -import { cn } from '@/lib/utils' -import { - Monitor, RefreshCw, AlertCircle, - CheckCircle2, XCircle, Minus, -} from 'lucide-react' - -// ============================================================================= -// Types -// ============================================================================= - -interface ServiceHealth { - name: string - status: 'healthy' | 'warning' | 'critical' | 'unknown' - latency_ms: number | null - uptime_pct: number | null - last_checked: string -} - -interface DashboardResponse { - healthy_count: number - warning_count: number - critical_count: number - total_count: number - services: ServiceHealth[] - timestamp: string -} - -// ============================================================================= -// 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 STATUS_ICON = { - healthy: , - warning: , - critical: , - unknown: , -} - -const STATUS_BADGE = { - healthy: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20', - warning: 'bg-status-warning/10 text-status-warning border-status-warning/20', - critical: 'bg-status-critical/10 text-status-critical border-status-critical/20', - unknown: 'bg-nothing-gray-100 text-nothing-gray-500 border-nothing-gray-200', -} - -// ============================================================================= -// Sub-component: Health Summary -// ============================================================================= - -function HealthSummary({ data, t }: { data: DashboardResponse; t: (key: string) => string }) { - const total = data.total_count || 0 - const pct = (count: number) => total > 0 ? `${Math.round(count / total * 100)}%` : '0%' - - return ( -
- {[ - { label: t('healthy'), count: data.healthy_count, color: 'text-status-healthy', bg: 'bg-status-healthy/10' }, - { label: t('warning'), count: data.warning_count, color: 'text-status-warning', bg: 'bg-status-warning/10' }, - { label: t('critical'), count: data.critical_count, color: 'text-status-critical', bg: 'bg-status-critical/10' }, - ].map(item => ( -
-
- {item.count} -
-
- {item.label} · {pct(item.count)} -
-
- ))} -
- ) -} - -// ============================================================================= -// Page -// ============================================================================= +import { MonitoringPanel } from '@/components/panels/MonitoringPanel' export default function MonitoringPage({ params }: { params: { locale: string } }) { - const t = useTranslations('monitoring') - const tNav = useTranslations('nav') - const tCommon = useTranslations('common') - const tAlerts = useTranslations('alerts') - const hosts = useHosts() - - const { metrics, isLoading: metricsLoading } = useGlobalPulseMetrics({ - pollInterval: 30000, - enablePolling: true, - }) - - const [dashboard, setDashboard] = useState(null) - const [dashLoading, setDashLoading] = useState(true) - const [dashError, setDashError] = useState(null) - const abortRef = useRef(null) - - const fetchDashboard = useCallback(async () => { - const base = getApiBaseUrl() - if (!base) return - - abortRef.current?.abort() - const ctrl = new AbortController() - abortRef.current = ctrl - - setDashLoading(true) - setDashError(null) - try { - const res = await fetch(`${base}/api/v1/dashboard`, { signal: ctrl.signal }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - setDashboard(await res.json()) - } catch (e) { - if (e instanceof Error && e.name === 'AbortError') return - setDashError(e instanceof Error ? e.message : 'Unknown error') - } finally { - setDashLoading(false) - } - }, []) - - useEffect(() => { - fetchDashboard() - const id = setInterval(fetchDashboard, 30000) - return () => { - clearInterval(id) - abortRef.current?.abort() - } - }, [fetchDashboard]) - - const isLoading = metricsLoading || dashLoading - return ( - {/* Header */} -
-
-

- - {tNav('monitoring')} -

-

- {tAlerts('autoRefresh', { seconds: 30 })} -

-
- -
- - {/* Error */} - {dashError && ( -
- - {dashError} -
- )} - - {/* Health Summary */} - {dashboard && } - - {/* Gold Metrics */} -
-

- {t('goldMetrics')} -

- {metricsLoading && metrics.length === 0 ? ( -
- -
- ) : ( - - )} -
- - {/* Host Grid */} -
-

- {t('hostStatus')} -

- -
- - {/* Service Table */} - {dashboard && dashboard.services && dashboard.services.length > 0 && ( -
-

- {t('serviceList')} -

-
- - - - {[t('serviceName'), t('status'), t('latency'), t('uptime'), t('lastCheck')].map(h => ( - - ))} - - - - {dashboard.services.map((svc, i) => ( - - - - - - - - ))} - -
- {h} -
- {svc.name} - - - {STATUS_ICON[svc.status]} - {svc.status} - - - {svc.latency_ms != null ? `${svc.latency_ms}ms` : '--'} - - {svc.uptime_pct != null ? `${svc.uptime_pct.toFixed(2)}%` : '--'} - - {svc.last_checked - ? new Date(svc.last_checked).toLocaleTimeString() - : '--'} -
-
-
- )} +
) } diff --git a/apps/web/src/app/[locale]/observability/page.tsx b/apps/web/src/app/[locale]/observability/page.tsx index 0a18b5bb..005b53c7 100644 --- a/apps/web/src/app/[locale]/observability/page.tsx +++ b/apps/web/src/app/[locale]/observability/page.tsx @@ -4,34 +4,28 @@ * 可觀測性 (/observability) — Sprint 5 整合頁面 * ================================================ * 整合: 服務監控 + APM + 錯誤追蹤 + 應用 + 服務目錄 - * 使用 PageTabs 共用元件,每個 Tab 載入對應的現有頁面內容 * - * 零假數據: 所有 Tab 內容串接現有真實 API + * Tab 1 (monitoring) 使用 Panel 元件 (無雙重 AppLayout) + * Tab 2-5 暫時用 lazy import (未來逐步抽取 Panel) + * + * 零假數據: 全部串接真實 API * * 建立時間: 2026-04-08 (台北時區) - * 建立者: Claude Code (Sprint 5 Phase 3) + * 更新時間: 2026-04-09 — Panel 抽取 (monitoring) */ 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 { LobsterLoading } from '@/components/shared/lobster-loading' -// 延遲載入現有頁面內容 (避免一次載入所有 Tab) -// 注意: 這些直接 import 現有頁面的內部邏輯,不是 Mock -const MonitoringContent = lazy(() => import('@/app/[locale]/monitoring/page').then(m => ({ default: m.default }))) -const APMContent = lazy(() => import('@/app/[locale]/apm/page').then(m => ({ default: m.default }))) -const ErrorsContent = lazy(() => import('@/app/[locale]/errors/page').then(m => ({ default: m.default }))) -const AppsContent = lazy(() => import('@/app/[locale]/apps/page').then(m => ({ default: m.default }))) -const ServicesContent = lazy(() => import('@/app/[locale]/services/page').then(m => ({ default: m.default }))) - -function TabSkeleton() { - return ( -
- 載入中... -
- ) -} +// Tab 2-5: 暫時 lazy import 原始頁面 (含 AppLayout,未來抽取) +const APMContent = lazy(() => import('@/app/[locale]/apm/page')) +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 } }) { const t = useTranslations('nav') @@ -40,47 +34,27 @@ export default function ObservabilityPage({ params }: { params: { locale: string { id: 'monitoring', label: t('monitoring'), - content: ( - }> - - - ), + content: , // Panel 元件,無雙重 AppLayout }, { id: 'apm', label: t('apm'), - content: ( - }> - - - ), + content: }>, }, { id: 'errors', label: t('errors'), - content: ( - }> - - - ), + content: }>, }, { id: 'apps', label: t('apps'), - content: ( - }> - - - ), + content: }>, }, { id: 'services', label: t('services'), - content: ( - }> - - - ), + content: }>, }, ] diff --git a/apps/web/src/components/panels/MonitoringPanel.tsx b/apps/web/src/components/panels/MonitoringPanel.tsx new file mode 100644 index 00000000..1820f6b1 --- /dev/null +++ b/apps/web/src/components/panels/MonitoringPanel.tsx @@ -0,0 +1,230 @@ +'use client' + +/** + * MonitoringPanel — 服務監控面板 (不含 AppLayout) + * ================================================= + * Sprint 5: 從 /monitoring/page.tsx 抽取 + * 供原始頁面和整合頁面 (/observability) 共用 + * 零假數據: 串接真實 /api/v1/dashboard API + * + * 建立時間: 2026-04-09 (台北時區) + */ + +import { useState, useEffect, useCallback, useRef } from 'react' +import { useTranslations } from 'next-intl' +import { useGlobalPulseMetrics } from '@/hooks/useGlobalPulseMetrics' +import { useHosts } from '@/stores/dashboard.store' +import { GlobalPulseChart } from '@/components/charts/global-pulse-chart' +import { HostGrid } from '@/components/infra/host-grid' +import { cn } from '@/lib/utils' +import { + Monitor, RefreshCw, AlertCircle, + CheckCircle2, XCircle, Minus, +} from 'lucide-react' + +// ============================================================================= +// Types +// ============================================================================= + +interface ServiceHealth { + name: string + status: 'healthy' | 'warning' | 'critical' | 'unknown' + latency_ms: number | null + uptime_pct: number | null + last_checked: string +} + +interface DashboardResponse { + healthy_count: number + warning_count: number + critical_count: number + total_count: number + services: ServiceHealth[] + timestamp: string +} + +// ============================================================================= +// Helpers +// ============================================================================= + +const getApiBaseUrl = () => { + if (typeof window === 'undefined') return '' + return process.env.NEXT_PUBLIC_API_URL ?? '' +} + +const STATUS_ICON = { + healthy: , + warning: , + critical: , + unknown: , +} + +const STATUS_BADGE = { + healthy: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20', + warning: 'bg-status-warning/10 text-status-warning border-status-warning/20', + critical: 'bg-status-critical/10 text-status-critical border-status-critical/20', + unknown: 'bg-nothing-gray-100 text-nothing-gray-500 border-nothing-gray-200', +} + +function HealthSummary({ data, t }: { data: DashboardResponse; t: (key: string) => string }) { + const total = data.total_count || 0 + const pct = (count: number) => total > 0 ? `${Math.round(count / total * 100)}%` : '0%' + + return ( +
+ {[ + { label: t('healthy'), count: data.healthy_count, color: 'text-status-healthy', bg: 'bg-status-healthy/10' }, + { label: t('warning'), count: data.warning_count, color: 'text-status-warning', bg: 'bg-status-warning/10' }, + { label: t('critical'), count: data.critical_count, color: 'text-status-critical', bg: 'bg-status-critical/10' }, + ].map(item => ( +
+
+ {item.count} +
+
+ {item.label} · {pct(item.count)} +
+
+ ))} +
+ ) +} + +// ============================================================================= +// Panel 元件 (不含 AppLayout) +// ============================================================================= + +export function MonitoringPanel() { + const t = useTranslations('monitoring') + const tNav = useTranslations('nav') + const tCommon = useTranslations('common') + const tAlerts = useTranslations('alerts') + const hosts = useHosts() + + const { metrics, isLoading: metricsLoading } = useGlobalPulseMetrics({ + pollInterval: 30000, + enablePolling: true, + }) + + const [dashboard, setDashboard] = useState(null) + const [dashLoading, setDashLoading] = useState(true) + const [dashError, setDashError] = useState(null) + const abortRef = useRef(null) + + const fetchDashboard = useCallback(async () => { + const base = getApiBaseUrl() + if (!base) return + + abortRef.current?.abort() + const ctrl = new AbortController() + abortRef.current = ctrl + + setDashLoading(true) + setDashError(null) + try { + const res = await fetch(`${base}/api/v1/dashboard`, { signal: ctrl.signal }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + setDashboard(await res.json()) + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') return + setDashError(e instanceof Error ? e.message : 'Unknown error') + } finally { + setDashLoading(false) + } + }, []) + + useEffect(() => { + fetchDashboard() + const id = setInterval(fetchDashboard, 30000) + return () => { + clearInterval(id) + abortRef.current?.abort() + } + }, [fetchDashboard]) + + const isLoading = metricsLoading || dashLoading + + return ( + <> + {/* Header */} +
+
+

+ + {tNav('monitoring')} +

+

+ {tAlerts('autoRefresh', { seconds: 30 })} +

+
+ +
+ + {/* Gold Metrics */} + {metrics && } + + {/* Health Summary */} + {dashboard && t(key)} />} + + {/* Host Grid */} + {hosts.length > 0 && ( +
+

+ {t('hostStatus')} +

+ +
+ )} + + {/* Service Table */} + {dashboard?.services && dashboard.services.length > 0 && ( +
+

+ {t('serviceHealth')} +

+
+ + + + + + + + + + + {dashboard.services.map((svc) => ( + + + + + + + ))} + +
{t('service')}{t('status')}{t('latency')}{t('uptime')}
{svc.name} + + {STATUS_ICON[svc.status]} + {t(svc.status)} + + {svc.latency_ms != null ? `${svc.latency_ms}ms` : '--'}{svc.uptime_pct != null ? `${svc.uptime_pct}%` : '--'}
+
+
+ )} + + ) +} + +export default MonitoringPanel