优化服务监控统计

This commit is contained in:
dushixiang committed 2025-12-02 19:15:12 +08:00
1 parent 8e1bc50ee6
commit a1e5ab191f
8 files changed
+266 -92

No files matched your search

+2
View File
@@ -193,6 +193,7 @@ func setupApi(app *orz.App, components *AppComponents) {
// 监控统计数据(公开访问,支持可选认证)- 用于公共展示页面
publicApiWithOptionalAuth.GET("/monitors", components.MonitorHandler.GetMonitors)
publicApiWithOptionalAuth.GET("/monitors/:id/stats", components.MonitorHandler.GetStatsByID)
publicApiWithOptionalAuth.GET("/monitors/:id/agents", components.MonitorHandler.GetAgentStatsByID)
publicApiWithOptionalAuth.GET("/monitors/:id/history", components.MonitorHandler.GetHistoryByID)
// Logo(公开访问)- 用于公共页面只获取 Logo
@@ -309,6 +310,7 @@ func autoMigrate(database *gorm.DB) error {
&models.AggregatedDiskIOMetricModel{},
&models.AggregatedGPUMetricModel{},
&models.AggregatedTemperatureMetricModel{},
&models.AggregatedMonitorMetricModel{},
&models.AggregationProgress{},
)
}
+18
View File
@@ -158,6 +158,24 @@ func (h *MonitorHandler) GetStatsByID(c echo.Context) error {
return orz.Ok(c, stats)
}
// GetAgentStatsByID 获取指定监控任务各探针的统计数据(公开接口,已登录返回全部,未登录返回公开可见)
func (h *MonitorHandler) GetAgentStatsByID(c echo.Context) error {
id := c.Param("id")
ctx := c.Request().Context()
// 验证监控任务访问权限
if _, err := h.monitorService.GetMonitorByAuth(ctx, id, utils.IsAuthenticated(c)); err != nil {
return err
}
stats, err := h.monitorService.GetMonitorAgentStats(ctx, id)
if err != nil {
return err
}
return orz.Ok(c, stats)
}
// GetHistoryByID 获取指定监控任务的历史响应时间数据(公开接口,已登录返回全部,未登录返回公开可见)
func (h *MonitorHandler) GetHistoryByID(c echo.Context) error {
id := c.Param("id")
+21
View File
@@ -331,3 +331,24 @@ type AggregationProgress struct {
func (AggregationProgress) TableName() string {
return "aggregation_progress"
}
// AggregatedMonitorMetricModel 监控指标聚合表
type AggregatedMonitorMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
MonitorID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:1;uniqueIndex:ux_monitoragg_bucket,priority:1" json:"monitorId"`
AgentID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:2;uniqueIndex:ux_monitoragg_bucket,priority:2" json:"agentId"`
BucketSeconds int `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:3;uniqueIndex:ux_monitoragg_bucket,priority:3" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:4;uniqueIndex:ux_monitoragg_bucket,priority:4" json:"bucketStart"` // 毫秒
AvgResponse float64 `json:"avgResponse"` // 平均响应时间(ms)
MaxResponse int64 `json:"maxResponse"` // 最大响应时间(ms)
MinResponse int64 `json:"minResponse"` // 最小响应时间(ms)
SuccessCount int64 `json:"successCount"` // 成功次数
TotalCount int64 `json:"totalCount"` // 总次数
SuccessRate float64 `json:"successRate"` // 成功率(百分比)
LastStatus string `json:"lastStatus"` // 最后状态
LastError string `json:"lastError"` // 最后错误信息
}
func (AggregatedMonitorMetricModel) TableName() string {
return "monitor_metrics_aggs"
}
+67
View File
@@ -923,3 +923,70 @@ func (r *MetricRepo) GetAggregationProgress(ctx context.Context, metricType stri
}
return &progress, nil
}
// ----------- 监控指标聚合表操作 -----------
// AggregateMonitorMetricsToAgg 将原始监控数据聚合到聚合表
func (r *MetricRepo) AggregateMonitorMetricsToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO monitor_metrics_aggs (
monitor_id, agent_id, bucket_seconds, bucket_start,
avg_response, max_response, min_response,
success_count, total_count, success_rate,
last_status, last_error
)
WITH ranked_metrics AS (
SELECT
monitor_id,
agent_id,
(timestamp / ?) * ? as bucket_start,
response_time,
status,
error,
timestamp,
ROW_NUMBER() OVER (PARTITION BY monitor_id, agent_id, (timestamp / ?) * ? ORDER BY timestamp DESC) as rn
FROM monitor_metrics
WHERE timestamp >= ? AND timestamp < ?
)
SELECT
monitor_id,
agent_id,
? as bucket_seconds,
bucket_start,
AVG(response_time) as avg_response,
MAX(response_time) as max_response,
MIN(response_time) as min_response,
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as success_count,
COUNT(*) as total_count,
CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / CAST(COUNT(*) AS REAL) * 100 as success_rate,
MAX(CASE WHEN rn = 1 THEN status END) as last_status,
MAX(CASE WHEN rn = 1 THEN error END) as last_error
FROM ranked_metrics
GROUP BY monitor_id, agent_id, bucket_start
ON CONFLICT (monitor_id, agent_id, bucket_seconds, bucket_start) DO UPDATE SET
avg_response = EXCLUDED.avg_response,
max_response = EXCLUDED.max_response,
min_response = EXCLUDED.min_response,
success_count = EXCLUDED.success_count,
total_count = EXCLUDED.total_count,
success_rate = EXCLUDED.success_rate,
last_status = EXCLUDED.last_status,
last_error = EXCLUDED.last_error
`, bucketMs, bucketMs, bucketMs, bucketMs, start, end, bucketSeconds).Error
}
// GetMonitorMetricsAgg 从聚合表获取监控指标(按探针和时间间隔聚合)
func (r *MetricRepo) GetMonitorMetricsAgg(ctx context.Context, monitorID string, start, end int64, bucketSeconds int) ([]AggregatedMonitorMetric, error) {
var metrics []AggregatedMonitorMetric
err := r.db.WithContext(ctx).
Table("monitor_metrics_aggs").
Select(`bucket_start as timestamp, agent_id,
avg_response, max_response, min_response,
success_count, total_count, success_rate,
last_status, last_error as last_error_msg`).
Where("monitor_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", monitorID, bucketSeconds, start, end).
Order("bucket_start, agent_id").
Scan(&metrics).Error
return metrics, err
}
+1
View File
@@ -676,6 +676,7 @@ func (s *MetricService) runAggregation(ctx context.Context) {
s.aggregateMetric(ctx, "disk_io", bucket, retention, s.metricRepo.AggregateDiskIOToAgg)
s.aggregateMetric(ctx, "gpu", bucket, retention, s.metricRepo.AggregateGPUToAgg)
s.aggregateMetric(ctx, "temperature", bucket, retention, s.metricRepo.AggregateTemperatureToAgg)
s.aggregateMetric(ctx, "monitor", bucket, retention, s.metricRepo.AggregateMonitorMetricsToAgg)
}
}
+103 -53
View File
@@ -11,6 +11,7 @@ import (
"github.com/dushixiang/pika/internal/protocol"
"github.com/dushixiang/pika/internal/repo"
ws "github.com/dushixiang/pika/internal/websocket"
"github.com/go-orz/cache"
"github.com/go-orz/orz"
"github.com/go-orz/toolkit"
"github.com/google/uuid"
@@ -27,6 +28,11 @@ type MonitorService struct {
metricRepo *repo.MetricRepo
monitorStatsRepo *repo.MonitorStatsRepo
wsManager *ws.Manager
// 监控概览缓存:缓存监控任务列表(使用不同的 key 区分 public 和 private)
overviewCache cache.Cache[string, []PublicMonitorOverview]
// 监控统计缓存:缓存单个监控任务的统计数据
statsCache cache.Cache[string, []models.MonitorStats]
}
func NewMonitorService(logger *zap.Logger, db *gorm.DB, wsManager *ws.Manager) *MonitorService {
@@ -37,8 +43,11 @@ func NewMonitorService(logger *zap.Logger, db *gorm.DB, wsManager *ws.Manager) *
agentRepo: repo.NewAgentRepo(db),
metricRepo: repo.NewMetricRepo(db),
monitorStatsRepo: repo.NewMonitorStatsRepo(db),
wsManager: wsManager,
wsManager: wsManager,
// 缓存 5 分钟,避免频繁查询
overviewCache: cache.New[string, []PublicMonitorOverview](5 * time.Minute),
statsCache: cache.New[string, []models.MonitorStats](5 * time.Minute),
}
}
@@ -178,6 +187,18 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error {
// ListByAuth 返回公开展示所需的监控配置和汇总统计
func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ([]PublicMonitorOverview, error) {
// 构建缓存键:根据认证状态使用不同的 key
cacheKey := "overview:public"
if isAuthenticated {
cacheKey = "overview:private"
}
// 尝试从缓存获取
if cachedResult, ok := s.overviewCache.Get(cacheKey); ok {
return cachedResult, nil
}
// 缓存未命中,查询数据库
// 获取符合权限的监控任务列表
monitors, err := s.FindByAuth(ctx, isAuthenticated)
if err != nil {
@@ -185,7 +206,10 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) (
}
if len(monitors) == 0 {
return []PublicMonitorOverview{}, nil
emptyResult := []PublicMonitorOverview{}
// 缓存空结果
s.overviewCache.Set(cacheKey, emptyResult, 5*time.Minute)
return emptyResult, nil
}
// 提取监控任务ID列表
@@ -194,7 +218,7 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) (
monitorIds = append(monitorIds, monitor.ID)
}
// 批量获取统计数据
// 批量获取统计数据(已经由后台任务预计算好)
statsList, err := s.monitorStatsRepo.FindByMonitorIdIn(ctx, monitorIds)
if err != nil {
return nil, err
@@ -206,55 +230,23 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) (
statsMap[stats.MonitorId] = append(statsMap[stats.MonitorId], stats)
}
// 获取所有探针列表,用于过滤有效的统计数据
agents, err := s.agentRepo.FindAll(ctx)
if err != nil {
return nil, err
}
// 构建监控概览列表
items := make([]PublicMonitorOverview, 0, len(monitors))
for _, monitor := range monitors {
// 计算当前监控任务关联的目标探针
targetAgents := s.resolveTargetAgents(monitor, agents)
// 过滤出目标探针的统计数据
filteredStats := s.filterStatsByAgents(statsMap[monitor.ID], targetAgents)
// 聚合统计数据
summary := aggregateMonitorStats(filteredStats)
// 直接聚合预计算的统计数据
summary := aggregateMonitorStats(statsMap[monitor.ID])
// 构建监控概览对象
item := s.buildMonitorOverview(monitor, summary)
items = append(items, item)
}
// 缓存结果
s.overviewCache.Set(cacheKey, items, 5*time.Minute)
return items, nil
}
// filterStatsByAgents 过滤出指定探针的统计数据
func (s *MonitorService) filterStatsByAgents(stats []models.MonitorStats, targetAgents []models.Agent) []models.MonitorStats {
if len(stats) == 0 || len(targetAgents) == 0 {
return []models.MonitorStats{}
}
// 构建目标探针ID映射
targetAgentsMap := make(map[string]bool, len(targetAgents))
for _, agent := range targetAgents {
targetAgentsMap[agent.ID] = true
}
// 过滤统计数据
filteredStats := make([]models.MonitorStats, 0, len(stats))
for _, stat := range stats {
if targetAgentsMap[stat.AgentID] {
filteredStats = append(filteredStats, stat)
}
}
return filteredStats
}
// buildMonitorOverview 构建监控概览对象
func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, summary monitorOverviewSummary) PublicMonitorOverview {
// 根据 ShowTargetPublic 字段决定是否返回真实的 Target
@@ -652,8 +644,40 @@ func (s *MonitorService) calculateStatsForAgentMonitor(ctx context.Context, agen
return stats, nil
}
// GetMonitorStatsByID 获取监控任务的统计数据(所有探针)
func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID string) ([]models.MonitorStats, error) {
// GetMonitorStatsByID 获取监控任务的统计数据(聚合后的单个监控详情)
func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID string) (*PublicMonitorOverview, error) {
// 查询监控任务
monitor, err := s.MonitorRepo.FindById(ctx, monitorID)
if err != nil {
return nil, err
}
// 获取统计数据
statsList, err := s.monitorStatsRepo.FindByMonitorId(ctx, monitor.ID)
if err != nil {
return nil, err
}
// 聚合统计数据
summary := aggregateMonitorStats(statsList)
// 构建监控概览对象
overview := s.buildMonitorOverview(monitor, summary)
return &overview, nil
}
// GetMonitorAgentStats 获取监控任务各探针的统计数据(详细列表)
func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]models.MonitorStats, error) {
// 构建缓存键
cacheKey := fmt.Sprintf("agents:%s", monitorID)
// 尝试从缓存获取
if cachedResult, ok := s.statsCache.Get(cacheKey); ok {
return cachedResult, nil
}
// 缓存未命中,查询数据库
monitor, err := s.MonitorRepo.FindById(ctx, monitorID)
if err != nil {
return nil, err
@@ -695,43 +719,69 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri
filteredStatsList = append(filteredStatsList, stats)
}
// 缓存结果
s.statsCache.Set(cacheKey, filteredStatsList, 5*time.Minute)
return filteredStatsList, nil
}
// GetMonitorHistory 获取监控任务的历史响应时间数据
// 根据前端时间范围选择,直接使用聚合表数据
// 支持时间范围:15m, 30m, 1h, 3h, 6h, 12h, 1d, 3d, 7d
func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID, timeRange string) ([]repo.AggregatedMonitorMetric, error) {
monitor, err := s.MonitorRepo.FindById(ctx, monitorID)
if err != nil {
return nil, err
}
// 解析时间范围
// 根据前端时间范围,选择最优的聚合粒度
var duration time.Duration
var interval int // 聚合间隔(秒)
var bucketSeconds int // 使用的聚合 bucket(秒)
switch timeRange {
case "5m":
duration = 5 * time.Minute
interval = 15 // 15秒聚合一次
case "15m":
duration = 15 * time.Minute
interval = 30 // 30秒聚合一次
bucketSeconds = 60 // 1分钟聚合,约15个点
case "30m":
duration = 30 * time.Minute
interval = 60 // 1分钟聚合一次
bucketSeconds = 60 // 1分钟聚合,约30个点
case "1h":
duration = 1 * time.Hour
interval = 120 // 2分钟聚合一次
bucketSeconds = 300 // 5分钟聚合,约12个点
case "3h":
duration = 3 * time.Hour
bucketSeconds = 300 // 5分钟聚合,约36个点
case "6h":
duration = 6 * time.Hour
bucketSeconds = 900 // 15分钟聚合,约24个点
case "12h":
duration = 12 * time.Hour
bucketSeconds = 900 // 15分钟聚合,约48个点
case "1d", "24h":
duration = 24 * time.Hour
bucketSeconds = 1800 // 30分钟聚合,约48个点
case "3d":
duration = 3 * 24 * time.Hour
bucketSeconds = 3600 // 1小时聚合,约72个点
case "7d":
duration = 7 * 24 * time.Hour
bucketSeconds = 7200 // 2小时聚合,约84个点
default:
duration = 5 * time.Minute
interval = 15
// 默认 15 分钟
duration = 15 * time.Minute
bucketSeconds = 60
}
now := time.Now()
end := now.UnixMilli()
start := now.Add(-duration).UnixMilli()
return s.metricRepo.GetAggregatedMonitorMetrics(ctx, monitor.ID, start, end, interval)
// 对齐时间范围到 bucket 边界
bucketMs := int64(bucketSeconds * 1000)
start, end = alignTimeRangeToBucket(start, end, bucketMs)
// 直接使用聚合表查询
return s.metricRepo.GetMonitorMetricsAgg(ctx, monitor.ID, start, end, bucketSeconds)
}
// GetMonitorByAuth 根据认证状态获取监控任务(已登录返回全部,未登录返回公开可见)
+7 -2
View File
@@ -34,9 +34,14 @@ export const getPublicMonitors = () => {
return get<PublicMonitor[]>('/monitors');
};
// 公开接口 - 获取指定监控的统计数据
// 公开接口 - 获取指定监控的统计数据(聚合后的单个监控详情)
export const getMonitorStatsById = (id: string) => {
return get<MonitorStats[]>(`/monitors/${encodeURIComponent(id)}/stats`);
return get<PublicMonitor>(`/monitors/${encodeURIComponent(id)}/stats`);
};
// 公开接口 - 获取指定监控各探针的统计数据(详细列表)
export const getMonitorAgentStats = (id: string) => {
return get<MonitorStats[]>(`/monitors/${encodeURIComponent(id)}/agents`);
};
// 聚合的监控历史数据
+47 -37
View File
@@ -15,8 +15,8 @@ import {
} from 'lucide-react';
import type {TooltipProps} from 'recharts';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis,} from 'recharts';
import {type AggregatedMonitorMetric, getMonitorHistory, getMonitorStatsById} from '@/api/monitor.ts';
import type {MonitorStats} from '@/types';
import {type AggregatedMonitorMetric, getMonitorHistory, getMonitorStatsById, getMonitorAgentStats} from '@/api/monitor.ts';
import type {MonitorStats, PublicMonitor} from '@/types';
const formatTime = (ms: number): string => {
if (!ms || ms <= 0) return '0 ms';
@@ -74,12 +74,12 @@ const timeRangeOptions = [
{label: '15分钟', value: '15m'},
{label: '30分钟', value: '30m'},
{label: '1小时', value: '1h'},
// {label: '3小时', value: '3h'},
// {label: '6小时', value: '6h'},
// {label: '12小时', value: '12h'},
// {label: '1天', value: '1d'},
// {label: '3天', value: '3d'},
// {label: '7天', value: '7d'},
{label: '3小时', value: '3h'},
{label: '6小时', value: '6h'},
{label: '12小时', value: '12h'},
{label: '1天', value: '1d'},
{label: '3天', value: '3d'},
{label: '7天', value: '7d'},
]
const ChartPlaceholder = ({
@@ -318,11 +318,24 @@ const MonitorDetail = () => {
const [selectedAgent, setSelectedAgent] = useState<string>('all');
const [timeRange, setTimeRange] = useState<string>('15m');
const {data: monitorStats = [], isLoading} = useQuery<MonitorStats[]>({
queryKey: ['monitorStats', id],
// 获取监控详情(聚合数据)
const {data: monitorDetail, isLoading} = useQuery<PublicMonitor>({
queryKey: ['monitorDetail', id],
queryFn: async () => {
if (!id) throw new Error('Monitor ID is required');
const response = await getMonitorStatsById(id);
return response.data;
},
refetchInterval: 30000,
enabled: !!id,
});
// 获取各探针的统计数据
const {data: monitorStats = []} = useQuery<MonitorStats[]>({
queryKey: ['monitorAgentStats', id],
queryFn: async () => {
if (!id) return [];
const response = await getMonitorStatsById(id);
const response = await getMonitorAgentStats(id);
return response.data || [];
},
refetchInterval: 30000,
@@ -392,23 +405,20 @@ const MonitorDetail = () => {
return <LoadingSpinner/>;
}
if (monitorStats.length === 0) {
if (!monitorDetail) {
return <EmptyState/>;
}
const firstStat = monitorStats[0];
const monitorTitle = firstStat?.name ?? '监控详情';
const avgUptime24h = monitorStats.reduce((sum, s) => sum + s.uptime24h, 0) / monitorStats.length;
const avgUptime30d = monitorStats.reduce((sum, s) => sum + s.uptime30d, 0) / monitorStats.length;
const hasCert = firstStat.certExpiryDate > 0;
const certExpired = hasCert && firstStat.certExpiryDays < 0;
const certExpiringSoon = hasCert && firstStat.certExpiryDays >= 0 && firstStat.certExpiryDays < 30;
const monitorTitle = monitorDetail.name ?? '监控详情';
const hasCert = monitorDetail.certExpiryDate > 0;
const certExpired = hasCert && monitorDetail.certExpiryDays < 0;
const certExpiringSoon = hasCert && monitorDetail.certExpiryDays >= 0 && monitorDetail.certExpiryDays < 30;
const heroStats = [
{label: '监控类型', value: firstStat.type.toUpperCase()},
{label: '探针数量', value: `${monitorStats.length} 个`},
{label: '24h在线率', value: `${formatPercentValue(avgUptime24h)}%`},
{label: '30d在线率', value: `${formatPercentValue(avgUptime30d)}%`},
{label: '监控类型', value: monitorDetail.type.toUpperCase()},
{label: '探针数量', value: `${monitorDetail.agentCount} 个`},
{label: '24h在线率', value: `${formatPercentValue(monitorDetail.uptime24h)}%`},
{label: '30d在线率', value: `${formatPercentValue(monitorDetail.uptime30d)}%`},
];
return (
@@ -433,7 +443,7 @@ const MonitorDetail = () => {
<div className="flex items-start gap-4">
<div
className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 text-white">
{firstStat.type === 'tcp' ? (
{monitorDetail.type === 'tcp' ? (
<ServerIcon className="h-7 w-7"/>
) : (
<Globe className="h-7 w-7"/>
@@ -444,7 +454,7 @@ const MonitorDetail = () => {
<h1 className="text-3xl font-semibold">{monitorTitle}</h1>
</div>
<p className="mt-2 text-sm text-white/80">
{firstStat.target}
{monitorDetail.target}
</p>
<p className="text-xs text-white/60">公共视图 · 实时监控概览</p>
</div>
@@ -464,11 +474,11 @@ const MonitorDetail = () => {
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-white/70">
<span>监控 ID:{firstStat.id || id}</span>
<span>监控 ID:{monitorDetail.id || id}</span>
<span className="hidden h-1 w-1 rounded-full bg-white/30 sm:inline-block"/>
<span>探针数量:{monitorStats.length} 个</span>
<span>探针数量:{monitorDetail.agentCount} 个</span>
<span className="hidden h-1 w-1 rounded-full bg-white/30 sm:inline-block"/>
<span>目标:{firstStat.target}</span>
<span>目标:{monitorDetail.target}</span>
</div>
</div>
</section>
@@ -480,26 +490,26 @@ const MonitorDetail = () => {
<StatCard
icon={<Clock className="h-6 w-6"/>}
label="当前响应"
value={formatTime(firstStat.currentResponse)}
value={formatTime(monitorDetail.currentResponse)}
color="blue"
/>
<StatCard
icon={<Clock className="h-6 w-6"/>}
label="24h 平均响应"
value={formatTime(firstStat.avgResponse24h)}
value={formatTime(monitorDetail.avgResponse24h)}
color="blue"
/>
<StatCard
icon={<CheckCircle2 className="h-6 w-6"/>}
label="24h 在线率"
value={`${formatPercentValue(avgUptime24h)}%`}
color={avgUptime24h >= 99 ? 'emerald' : avgUptime24h >= 95 ? 'amber' : 'rose'}
value={`${formatPercentValue(monitorDetail.uptime24h)}%`}
color={monitorDetail.uptime24h >= 99 ? 'emerald' : monitorDetail.uptime24h >= 95 ? 'amber' : 'rose'}
/>
<StatCard
icon={<CheckCircle2 className="h-6 w-6"/>}
label="30d 在线率"
value={`${formatPercentValue(avgUptime30d)}%`}
color={avgUptime30d >= 99 ? 'emerald' : avgUptime30d >= 95 ? 'amber' : 'rose'}
value={`${formatPercentValue(monitorDetail.uptime30d)}%`}
color={monitorDetail.uptime30d >= 99 ? 'emerald' : monitorDetail.uptime30d >= 95 ? 'amber' : 'rose'}
/>
</div>
@@ -537,12 +547,12 @@ const MonitorDetail = () => {
? 'text-amber-700 dark:text-amber-200'
: 'text-slate-600 dark:text-slate-400'
}`}>
证书到期时间: {formatDate(firstStat.certExpiryDate)}
证书到期时间: {formatDate(monitorDetail.certExpiryDate)}
{certExpired ? (
<span
className="ml-1">(已过期 {Math.abs(firstStat.certExpiryDays)} 天)</span>
className="ml-1">(已过期 {Math.abs(monitorDetail.certExpiryDays)} 天)</span>
) : (
<span className="ml-1">(剩余 {firstStat.certExpiryDays} 天)</span>
<span className="ml-1">(剩余 {monitorDetail.certExpiryDays} 天)</span>
)}
</p>
{certExpired && (