refactor(web): ApmPanel 抽取 — /observability 的 monitoring+apm 兩個 Tab 無雙重 Layout
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:
@@ -1,128 +1,16 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* APM Page — 黃金指標 (Golden Signals)
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/metrics/gold 真實數據 + TimeSeriesChart 趨勢圖
|
||||
* APM Page — Sprint 5: 內容抽取到 ApmPanel
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { TimeSeriesChart } from '@/components/charts/time-series-chart'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
const SIGNOZ_URL = 'http://192.168.0.188:3301'
|
||||
|
||||
interface GoldMetricItem {
|
||||
label: string
|
||||
value: number | string
|
||||
unit: string | null
|
||||
trend: number[]
|
||||
status: string
|
||||
}
|
||||
|
||||
interface GoldMetricsResponse {
|
||||
timestamp: string
|
||||
service_name: string
|
||||
metrics: GoldMetricItem[]
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
healthy: '#22C55E',
|
||||
warning: '#F59E0B',
|
||||
critical: '#cc2200',
|
||||
unknown: '#87867f',
|
||||
}
|
||||
|
||||
const STATUS_CHART_COLOR: Record<string, 'success' | 'warning' | 'error' | 'primary'> = {
|
||||
healthy: 'success',
|
||||
warning: 'warning',
|
||||
critical: 'error',
|
||||
unknown: 'primary',
|
||||
}
|
||||
import { ApmPanel } from '@/components/panels/ApmPanel'
|
||||
|
||||
export default function ApmPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('apm')
|
||||
const [data, setData] = useState<GoldMetricsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/metrics/gold?service_name=awoooi-api&time_window_minutes=10`)
|
||||
.then(r => r.json())
|
||||
.then((d: GoldMetricsResponse) => { setData(d); setLoading(false) })
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</div>
|
||||
<a href={SIGNOZ_URL} target="_blank" rel="noopener noreferrer" style={{ fontSize: 12, fontWeight: 600, color: '#4A90D9', border: '0.5px solid rgba(74,144,217,0.3)', borderRadius: 6, padding: '5px 12px', textDecoration: 'none', fontFamily: 'var(--font-body), monospace', background: 'rgba(74,144,217,0.05)' }}>
|
||||
↗ {t('openSignoz')}
|
||||
</a>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
|
||||
) : data && data.metrics.length > 0 ? (
|
||||
<>
|
||||
{/* Metric Cards with Sparklines */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
{data.metrics.map((m, i) => {
|
||||
const trendPoints = (m.trend ?? []).map((v, idx) => ({ timestamp: idx, value: v }))
|
||||
const hasTrend = trendPoints.length > 1
|
||||
const chartColor = STATUS_CHART_COLOR[m.status] ?? 'primary'
|
||||
return (
|
||||
<div key={i} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px 12px' }}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{m.label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace', lineHeight: 1.2 }}>
|
||||
{typeof m.value === 'number' ? m.value.toFixed(2) : m.value}
|
||||
{m.unit && <span style={{ fontSize: 13, color: '#87867f', marginLeft: 4 }}>{m.unit}</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, marginBottom: hasTrend ? 10 : 0, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLOR[m.status] ?? '#87867f', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: STATUS_COLOR[m.status] ?? '#87867f', textTransform: 'uppercase' }}>{m.status}</span>
|
||||
</div>
|
||||
{hasTrend && (
|
||||
<TimeSeriesChart
|
||||
data={trendPoints}
|
||||
height={48}
|
||||
color={chartColor}
|
||||
unit={m.unit ?? undefined}
|
||||
showYAxis={false}
|
||||
showGradient={true}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>
|
||||
Service: <strong style={{ color: '#141413' }}>{data.service_name}</strong>
|
||||
{' · '}
|
||||
{new Date(data.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12 }}>
|
||||
<div style={{ padding: '60px 24px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 32, color: '#e0ddd4', marginBottom: 16 }}>◎</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace', marginBottom: 8 }}>{t('noData')}</div>
|
||||
<div style={{ fontSize: 12, color: '#87867f', fontFamily: 'var(--font-body), monospace', maxWidth: 340, margin: '0 auto' }}>{t('noDataDescription')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ApmPanel />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ 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 { ApmPanel } from '@/components/panels/ApmPanel'
|
||||
import { LobsterLoading } from '@/components/shared/lobster-loading'
|
||||
|
||||
// Tab 2-5: 暫時 lazy import 原始頁面 (含 AppLayout,未來抽取)
|
||||
const APMContent = lazy(() => import('@/app/[locale]/apm/page'))
|
||||
// Tab 3-5: 暫時 lazy import (未來抽取 Panel)
|
||||
const ErrorsContent = lazy(() => import('@/app/[locale]/errors/page'))
|
||||
const AppsContent = lazy(() => import('@/app/[locale]/apps/page'))
|
||||
const ServicesContent = lazy(() => import('@/app/[locale]/services/page'))
|
||||
@@ -39,7 +39,7 @@ export default function ObservabilityPage({ params }: { params: { locale: string
|
||||
{
|
||||
id: 'apm',
|
||||
label: t('apm'),
|
||||
content: <Suspense fallback={<LobsterLoading />}><APMContent params={params} /></Suspense>,
|
||||
content: <ApmPanel />,
|
||||
},
|
||||
{
|
||||
id: 'errors',
|
||||
|
||||
91
apps/web/src/components/panels/ApmPanel.tsx
Normal file
91
apps/web/src/components/panels/ApmPanel.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ApmPanel — APM 黃金指標面板 (不含 AppLayout)
|
||||
* Sprint 5: 從 /apm/page.tsx 抽取
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TimeSeriesChart } from '@/components/charts/time-series-chart'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
const SIGNOZ_URL = 'http://192.168.0.188:3301'
|
||||
|
||||
interface GoldMetricItem {
|
||||
label: string; value: number | string; unit: string | null; trend: number[]; status: string
|
||||
}
|
||||
interface GoldMetricsResponse {
|
||||
timestamp: string; service_name: string; metrics: GoldMetricItem[]
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = { healthy: '#22C55E', warning: '#F59E0B', critical: '#cc2200', unknown: '#87867f' }
|
||||
const STATUS_CHART_COLOR: Record<string, 'success' | 'warning' | 'error' | 'primary'> = { healthy: 'success', warning: 'warning', critical: 'error', unknown: 'primary' }
|
||||
|
||||
export function ApmPanel() {
|
||||
const t = useTranslations('apm')
|
||||
const [data, setData] = useState<GoldMetricsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/metrics/gold?service_name=awoooi-api&time_window_minutes=10`)
|
||||
.then(r => r.json())
|
||||
.then((d: GoldMetricsResponse) => { setData(d); setLoading(false) })
|
||||
.catch(err => { setError(String(err)); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
|
||||
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
|
||||
</div>
|
||||
<a href={SIGNOZ_URL} target="_blank" rel="noopener noreferrer" style={{ fontSize: 12, fontWeight: 600, color: '#4A90D9', border: '0.5px solid rgba(74,144,217,0.3)', borderRadius: 6, padding: '5px 12px', textDecoration: 'none', fontFamily: 'var(--font-body), monospace', background: 'rgba(74,144,217,0.05)' }}>
|
||||
↗ {t('openSignoz')}
|
||||
</a>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontSize: 13 }}>{t('error')}</div>
|
||||
) : data && data.metrics.length > 0 ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
{data.metrics.map((m, i) => {
|
||||
const trendPoints = (m.trend ?? []).map((v, idx) => ({ timestamp: idx, value: v }))
|
||||
const hasTrend = trendPoints.length > 1
|
||||
const chartColor = STATUS_CHART_COLOR[m.status] ?? 'primary'
|
||||
return (
|
||||
<div key={i} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px 12px' }}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{m.label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', lineHeight: 1.2 }}>
|
||||
{typeof m.value === 'number' ? m.value.toFixed(2) : m.value}
|
||||
{m.unit && <span style={{ fontSize: 13, color: '#87867f', marginLeft: 4 }}>{m.unit}</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLOR[m.status] ?? '#87867f', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: STATUS_COLOR[m.status] ?? '#87867f', textTransform: 'uppercase' }}>{m.status}</span>
|
||||
</div>
|
||||
{hasTrend && <TimeSeriesChart data={trendPoints} height={48} color={chartColor} unit={m.unit ?? undefined} showYAxis={false} showGradient={true} className="mt-1" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: 11, color: '#87867f' }}>
|
||||
Service: <strong style={{ color: '#141413' }}>{data.service_name}</strong>{' · '}{new Date(data.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '60px 24px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 32, color: '#e0ddd4', marginBottom: 16 }}>◎</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#141413', marginBottom: 8 }}>{t('noData')}</div>
|
||||
<div style={{ fontSize: 12, color: '#87867f', maxWidth: 340, margin: '0 auto' }}>{t('noDataDescription')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,3 +13,4 @@
|
||||
*/
|
||||
|
||||
export { MonitoringPanel } from './MonitoringPanel'
|
||||
export { ApmPanel } from './ApmPanel'
|
||||
|
||||
Reference in New Issue
Block a user