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

@@ -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