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 && (
-
- )}
-
- {/* 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 => (
- |
- {h}
- |
- ))}
-
-
-
- {dashboard.services.map((svc, i) => (
-
- |
- {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')}
+
+
+
+
+
+ | {t('service')} |
+ {t('status')} |
+ {t('latency')} |
+ {t('uptime')} |
+
+
+
+ {dashboard.services.map((svc) => (
+
+ | {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