refactor(web): MonitoringPanel 抽取 — 解決 /observability 雙重 AppLayout
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
230
apps/web/src/components/panels/MonitoringPanel.tsx
Normal file
230
apps/web/src/components/panels/MonitoringPanel.tsx
Normal 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
|
||||
Reference in New Issue
Block a user