refactor(web): MonitoringPanel 抽取 — 解決 /observability 雙重 AppLayout
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

This commit is contained in:
OG T
2026-04-09 10:40:07 +08:00
parent ec4ebaf310
commit 770667eed4
3 changed files with 253 additions and 295 deletions

View File

@@ -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: <CheckCircle2 className="w-4 h-4 text-status-healthy" />,
warning: <AlertCircle className="w-4 h-4 text-status-warning" />,
critical: <XCircle className="w-4 h-4 text-status-critical" />,
unknown: <Minus className="w-4 h-4 text-nothing-gray-400" />,
}
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 (
<div className="grid grid-cols-3 gap-3 mb-6">
{[
{ 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 => (
<div key={item.label} className={cn('rounded-lg border p-4 text-center', item.bg, 'border-transparent')}>
<div className={cn('text-3xl font-bold font-body tabular-nums', item.color)}>
{item.count}
</div>
<div className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-wider mt-1">
{item.label} · {pct(item.count)}
</div>
</div>
))}
</div>
)
}
// =============================================================================
// 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<DashboardResponse | null>(null)
const [dashLoading, setDashLoading] = useState(true)
const [dashError, setDashError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(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 (
<AppLayout locale={params.locale}>
{/* 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">
<Monitor className="w-6 h-6" />
{tNav('monitoring')}
</h2>
<p className="mt-1 text-sm text-nothing-gray-500 font-body">
{tAlerts('autoRefresh', { seconds: 30 })}
</p>
</div>
<button
onClick={fetchDashboard}
disabled={isLoading}
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', isLoading && 'animate-spin')} />
{tCommon('refresh')}
</button>
</div>
{/* Error */}
{dashError && (
<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">{dashError}</span>
</div>
)}
{/* Health Summary */}
{dashboard && <HealthSummary data={dashboard} t={t} />}
{/* Gold Metrics */}
<div className="mb-6">
<h3 className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-widest mb-3">
{t('goldMetrics')}
</h3>
{metricsLoading && metrics.length === 0 ? (
<div className="h-32 rounded-lg bg-nothing-gray-50 border border-nothing-gray-200 flex items-center justify-center">
<RefreshCw className="w-5 h-5 animate-spin text-nothing-gray-400" />
</div>
) : (
<GlobalPulseChart metrics={metrics} />
)}
</div>
{/* Host Grid */}
<div className="mb-6">
<h3 className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-widest mb-3">
{t('hostStatus')}
</h3>
<HostGrid hosts={hosts} />
</div>
{/* Service Table */}
{dashboard && dashboard.services && dashboard.services.length > 0 && (
<div>
<h3 className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-widest mb-3">
{t('serviceList')}
</h3>
<div className="rounded-lg border border-nothing-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-nothing-gray-50 border-b border-nothing-gray-200">
{[t('serviceName'), t('status'), t('latency'), t('uptime'), t('lastCheck')].map(h => (
<th key={h} className="px-4 py-2.5 text-left font-body text-[10px] uppercase text-nothing-gray-500 tracking-wider">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{dashboard.services.map((svc, i) => (
<tr key={svc.name} className={cn(
'border-b border-nothing-gray-100 last:border-0',
i % 2 === 0 ? 'bg-white' : 'bg-nothing-gray-50/50'
)}>
<td className="px-4 py-3 font-body text-sm text-nothing-black font-medium">
{svc.name}
</td>
<td className="px-4 py-3">
<span className={cn(
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-[11px] font-body font-bold uppercase',
STATUS_BADGE[svc.status]
)}>
{STATUS_ICON[svc.status]}
{svc.status}
</span>
</td>
<td className="px-4 py-3 font-body text-sm text-nothing-gray-700">
{svc.latency_ms != null ? `${svc.latency_ms}ms` : '--'}
</td>
<td className="px-4 py-3 font-body text-sm text-nothing-gray-700">
{svc.uptime_pct != null ? `${svc.uptime_pct.toFixed(2)}%` : '--'}
</td>
<td className="px-4 py-3 font-body text-xs text-nothing-gray-500">
{svc.last_checked
? new Date(svc.last_checked).toLocaleTimeString()
: '--'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<MonitoringPanel />
</AppLayout>
)
}

View File

@@ -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 (
<div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>
...
</div>
)
}
// 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: (
<Suspense fallback={<TabSkeleton />}>
<MonitoringContent params={params} />
</Suspense>
),
content: <MonitoringPanel />, // Panel 元件,無雙重 AppLayout
},
{
id: 'apm',
label: t('apm'),
content: (
<Suspense fallback={<TabSkeleton />}>
<APMContent params={params} />
</Suspense>
),
content: <Suspense fallback={<LobsterLoading />}><APMContent params={params} /></Suspense>,
},
{
id: 'errors',
label: t('errors'),
content: (
<Suspense fallback={<TabSkeleton />}>
<ErrorsContent params={params} />
</Suspense>
),
content: <Suspense fallback={<LobsterLoading />}><ErrorsContent params={params} /></Suspense>,
},
{
id: 'apps',
label: t('apps'),
content: (
<Suspense fallback={<TabSkeleton />}>
<AppsContent params={params} />
</Suspense>
),
content: <Suspense fallback={<LobsterLoading />}><AppsContent params={params} /></Suspense>,
},
{
id: 'services',
label: t('services'),
content: (
<Suspense fallback={<TabSkeleton />}>
<ServicesContent params={params} />
</Suspense>
),
content: <Suspense fallback={<LobsterLoading />}><ServicesContent params={params} /></Suspense>,
},
]

View File

@@ -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: <CheckCircle2 className="w-4 h-4 text-status-healthy" />,
warning: <AlertCircle className="w-4 h-4 text-status-warning" />,
critical: <XCircle className="w-4 h-4 text-status-critical" />,
unknown: <Minus className="w-4 h-4 text-nothing-gray-400" />,
}
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 (
<div className="grid grid-cols-3 gap-3 mb-6">
{[
{ 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 => (
<div key={item.label} className={cn('rounded-lg border p-4 text-center', item.bg, 'border-transparent')}>
<div className={cn('text-3xl font-bold font-body tabular-nums', item.color)}>
{item.count}
</div>
<div className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-wider mt-1">
{item.label} · {pct(item.count)}
</div>
</div>
))}
</div>
)
}
// =============================================================================
// 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<DashboardResponse | null>(null)
const [dashLoading, setDashLoading] = useState(true)
const [dashError, setDashError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(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 */}
<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">
<Monitor className="w-6 h-6" />
{tNav('monitoring')}
</h2>
<p className="mt-1 text-sm text-nothing-gray-500 font-body">
{tAlerts('autoRefresh', { seconds: 30 })}
</p>
</div>
<button
onClick={fetchDashboard}
disabled={isLoading}
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', isLoading && 'animate-spin')} />
{tCommon('refresh')}
</button>
</div>
{/* Gold Metrics */}
{metrics && <GlobalPulseChart metrics={metrics} />}
{/* Health Summary */}
{dashboard && <HealthSummary data={dashboard} t={(key) => t(key)} />}
{/* Host Grid */}
{hosts.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-bold font-body text-nothing-gray-700 mb-3 uppercase tracking-wider">
{t('hostStatus')}
</h3>
<HostGrid hosts={hosts as any} />
</div>
)}
{/* Service Table */}
{dashboard?.services && dashboard.services.length > 0 && (
<div>
<h3 className="text-sm font-bold font-body text-nothing-gray-700 mb-3 uppercase tracking-wider">
{t('serviceHealth')}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm font-body">
<thead>
<tr className="border-b border-nothing-gray-200">
<th className="text-left py-2 text-nothing-gray-500 font-medium">{t('service')}</th>
<th className="text-left py-2 text-nothing-gray-500 font-medium">{t('status')}</th>
<th className="text-right py-2 text-nothing-gray-500 font-medium">{t('latency')}</th>
<th className="text-right py-2 text-nothing-gray-500 font-medium">{t('uptime')}</th>
</tr>
</thead>
<tbody>
{dashboard.services.map((svc) => (
<tr key={svc.name} className="border-b border-nothing-gray-100">
<td className="py-2 font-medium">{svc.name}</td>
<td className="py-2">
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs border', STATUS_BADGE[svc.status])}>
{STATUS_ICON[svc.status]}
{t(svc.status)}
</span>
</td>
<td className="py-2 text-right tabular-nums">{svc.latency_ms != null ? `${svc.latency_ms}ms` : '--'}</td>
<td className="py-2 text-right tabular-nums">{svc.uptime_pct != null ? `${svc.uptime_pct}%` : '--'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)
}
export default MonitoringPanel