优化服务监控

This commit is contained in:
dushixiang committed 2025-12-14 11:57:37 +08:00
1 parent 623cbecfb4
commit f66c82e56a
16 files changed
+719 -181

No files matched your search

+1
View File
@@ -195,6 +195,7 @@ func setupApi(app *orz.App, components *AppComponents) {
publicApiWithOptionalAuth.GET("/monitors/:id/stats", components.MonitorHandler.GetStatsByID)
publicApiWithOptionalAuth.GET("/monitors/:id/agents", components.MonitorHandler.GetAgentStatsByID)
publicApiWithOptionalAuth.GET("/monitors/:id/history", components.MonitorHandler.GetHistoryByID)
publicApiWithOptionalAuth.GET("/monitors/:id", components.MonitorHandler.GetDetail) // 整合版接口
// Logo(公开访问)- 用于公共页面只获取 Logo
publicApiWithOptionalAuth.GET("/logo", components.PropertyHandler.GetLogo)
+18
View File
@@ -160,6 +160,24 @@ func (h *MonitorHandler) GetStatsByID(c echo.Context) error {
return orz.Ok(c, stats)
}
// GetDetail 获取监控详情(整合版,公开接口,已登录返回全部,未登录返回公开可见)
func (h *MonitorHandler) GetDetail(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
}
detail, err := h.monitorService.GetMonitorDetail(ctx, id)
if err != nil {
return err
}
return orz.Ok(c, detail)
}
// GetAgentStatsByID 获取指定监控任务各探针的统计数据(公开接口,已登录返回全部,未登录返回公开可见)
func (h *MonitorHandler) GetAgentStatsByID(c echo.Context) error {
id := c.Param("id")
+36 -8
View File
@@ -11,12 +11,19 @@ type LatestMonitorMetrics struct {
// MonitorStatsResult 监控统计结果(所有探针的聚合数据)
type MonitorStatsResult struct {
Status string `json:"status"` // 聚合状态(up/down/unknown)
ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms)
CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳)
CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数
AgentCount int `json:"agentCount"` // 探针数量
LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间(毫秒时间戳)
Status string `json:"status"` // 聚合状态(up/down/unknown)
ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms)
ResponseTimeMin int64 `json:"responseTimeMin"` // 最快响应时间(ms)
ResponseTimeMax int64 `json:"responseTimeMax"` // 最慢响应时间(ms)
CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳)
CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数
AgentCount int `json:"agentCount"` // 探针数量
AgentStats struct {
Up int `json:"up"` // 正常探针数量
Down int `json:"down"` // 异常探针数量
Unknown int `json:"unknown"` // 未知状态探针数量
} `json:"agentStats"` // 探针状态分布
LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间(毫秒时间戳)
}
// PublicMonitorOverview 用于公开展示的监控配置及汇总数据
@@ -31,8 +38,29 @@ type PublicMonitorOverview struct {
Interval int `json:"interval"`
AgentCount int `json:"agentCount"`
Status string `json:"status"` // up/down/unknown
ResponseTime int64 `json:"responseTime"` // 当前响应时间(ms)
ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms)
ResponseTimeMin int64 `json:"responseTimeMin"` // 最快响应时间(ms)
ResponseTimeMax int64 `json:"responseTimeMax"` // 最慢响应时间(ms)
CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳)
CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数
LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间
AgentStats struct {
Up int `json:"up"` // 正常探针数量
Down int `json:"down"` // 异常探针数量
Unknown int `json:"unknown"` // 未知状态探针数量
} `json:"agentStats"` // 探针状态分布
LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间
}
// MonitorDetailResponse 监控详情响应(整合版)
type MonitorDetailResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Target string `json:"target"`
ShowTargetPublic bool `json:"showTargetPublic"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Interval int `json:"interval"`
Stats *MonitorStatsResult `json:"stats"`
Agents []protocol.MonitorData `json:"agents"`
}
+28 -8
View File
@@ -582,9 +582,10 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito
}
var totalResponseTime int64
var minResponseTime int64 = 9223372036854775807 // math.MaxInt64
var maxResponseTime int64
var lastCheckTime int64
hasUp := false
hasDown := false
var upCount, downCount, unknownCount int
hasCert := false
var minCertExpiryTime int64
var minCertDaysLeft int
@@ -592,14 +593,26 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito
for _, stat := range latestMetrics.Agents {
totalResponseTime += stat.ResponseTime
// 计算响应时间的最小值和最大值
if stat.ResponseTime < minResponseTime {
minResponseTime = stat.ResponseTime
}
if stat.ResponseTime > maxResponseTime {
maxResponseTime = stat.ResponseTime
}
if stat.CheckedAt > lastCheckTime {
lastCheckTime = stat.CheckedAt
}
if stat.Status == "up" {
hasUp = true
} else if stat.Status == "down" {
hasDown = true
// 统计各状态的探针数量
switch stat.Status {
case "up":
upCount++
case "down":
downCount++
default:
unknownCount++
}
if stat.CertExpiryTime > 0 {
@@ -616,12 +629,19 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito
if count > 0 {
result.ResponseTime = totalResponseTime / int64(count)
}
result.ResponseTimeMin = minResponseTime
result.ResponseTimeMax = maxResponseTime
result.LastCheckTime = lastCheckTime
// 填充探针状态分布
result.AgentStats.Up = upCount
result.AgentStats.Down = downCount
result.AgentStats.Unknown = unknownCount
// 聚合状态:只要有一个探针 up,整体就是 up
if hasUp {
if upCount > 0 {
result.Status = "up"
} else if hasDown {
} else if downCount > 0 {
result.Status = "down"
}
+45 -46
View File
@@ -12,7 +12,6 @@ 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/google/uuid"
"go.uber.org/zap"
@@ -29,9 +28,6 @@ type MonitorService struct {
metricService *MetricService
wsManager *ws.Manager
// 监控概览缓存:缓存监控任务列表(使用不同的 key 区分 public 和 private)
overviewCache cache.Cache[string, []metric.PublicMonitorOverview]
// 调度器引用(用于动态管理任务)
scheduler MonitorScheduler
}
@@ -52,9 +48,6 @@ func NewMonitorService(logger *zap.Logger, db *gorm.DB, metricService *MetricSer
metricRepo: repo.NewMetricRepo(db),
metricService: metricService,
wsManager: wsManager,
// 缓存 5 分钟,避免频繁查询
overviewCache: cache.New[string, []metric.PublicMonitorOverview](5 * time.Minute),
}
}
@@ -115,9 +108,6 @@ func (s *MonitorService) CreateMonitor(ctx context.Context, req *MonitorTaskRequ
return nil, err
}
// 清理缓存
s.clearCache(task.ID)
// 如果任务启用,添加到调度器
if task.Enabled && s.scheduler != nil {
if err := s.scheduler.AddTask(task.ID, task.Interval); err != nil {
@@ -165,9 +155,6 @@ func (s *MonitorService) UpdateMonitor(ctx context.Context, id string, req *Moni
return nil, err
}
// 清理缓存
s.clearCache(task.ID)
// 更新调度器
if s.scheduler != nil {
// 如果从禁用变为启用,或者间隔时间改变
@@ -214,9 +201,6 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error {
return err
}
// 清理缓存
s.clearCache(id)
// 从调度器中移除
if s.scheduler != nil {
s.scheduler.RemoveTask(id)
@@ -227,31 +211,12 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error {
// ListByAuth 返回公开展示所需的监控配置和汇总统计
func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ([]metric.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 {
return nil, err
}
if len(monitors) == 0 {
var emptyResult []metric.PublicMonitorOverview
// 缓存空结果
s.overviewCache.Set(cacheKey, emptyResult, 5*time.Minute)
return emptyResult, nil
}
// 构建监控概览列表
items := make([]metric.PublicMonitorOverview, 0, len(monitors))
for _, monitor := range monitors {
@@ -262,9 +227,6 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) (
items = append(items, item)
}
// 缓存结果
s.overviewCache.Set(cacheKey, items, 5*time.Minute)
return items, nil
}
@@ -276,7 +238,7 @@ func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, stats
target = "******"
}
return metric.PublicMonitorOverview{
overview := metric.PublicMonitorOverview{
ID: monitor.ID,
Name: monitor.Name,
Type: monitor.Type,
@@ -288,10 +250,17 @@ func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, stats
AgentCount: stats.AgentCount,
Status: stats.Status,
ResponseTime: stats.ResponseTime,
ResponseTimeMin: stats.ResponseTimeMin,
ResponseTimeMax: stats.ResponseTimeMax,
CertExpiryTime: stats.CertExpiryTime,
CertDaysLeft: stats.CertDaysLeft,
LastCheckTime: stats.LastCheckTime,
}
// 复制探针状态分布
overview.AgentStats = stats.AgentStats
return overview
}
// resolveTargetAgents 计算监控任务对应的目标探针范围
@@ -442,6 +411,43 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri
return &overview, nil
}
// GetMonitorDetail 获取监控详情(整合stats和agents)
func (s *MonitorService) GetMonitorDetail(ctx context.Context, monitorID string) (*metric.MonitorDetailResponse, error) {
// 查询监控任务
monitor, err := s.MonitorRepo.FindById(ctx, monitorID)
if err != nil {
return nil, err
}
// 查询聚合统计
stats := s.metricService.GetMonitorStats(monitorID)
// 查询各探针数据
agents := s.metricService.GetMonitorAgentStats(monitorID)
// 根据 ShowTargetPublic 字段决定是否返回真实的 Target
target := monitor.Target
if !monitor.ShowTargetPublic {
target = "******"
}
// 构建响应
detail := &metric.MonitorDetailResponse{
ID: monitor.ID,
Name: monitor.Name,
Type: monitor.Type,
Target: target,
ShowTargetPublic: monitor.ShowTargetPublic,
Description: monitor.Description,
Enabled: monitor.Enabled,
Interval: monitor.Interval,
Stats: stats,
Agents: agents,
}
return detail, nil
}
// GetMonitorHistory 获取监控任务的历史时序数据
// 直接返回 VictoriaMetrics 的原始时序数据,包含所有探针的独立序列
// 支持时间范围:15m, 30m, 1h, 3h, 6h, 12h, 1d, 3d, 7d
@@ -501,13 +507,6 @@ func (s *MonitorService) GetMonitorByAuth(ctx context.Context, id string, isAuth
return monitor, nil
}
// clearCache 清理监控任务相关的所有缓存
func (s *MonitorService) clearCache(monitorID string) {
// 清理概览缓存
s.overviewCache.Delete("overview:public")
s.overviewCache.Delete("overview:private")
}
// GetLatestMonitorMetricsByType 获取指定类型的最新监控指标(用于告警检查)
func (s *MonitorService) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]protocol.MonitorData, error) {
// 查询数据库
+6 -1
View File
@@ -1,5 +1,5 @@
import {del, get, post, put} from './request';
import type {MonitorListResponse, MonitorTask, MonitorTaskRequest, PublicMonitor} from '../types';
import type {MonitorDetail, MonitorListResponse, MonitorTask, MonitorTaskRequest, PublicMonitor} from '../types';
export const listMonitors = (page: number = 1, pageSize: number = 10, keyword?: string) => {
const params = new URLSearchParams();
@@ -44,6 +44,11 @@ export const getMonitorAgentStats = (id: string) => {
return get<AgentMonitorStat[]>(`/monitors/${encodeURIComponent(id)}/agents`);
};
// 公开接口 - 获取监控详情(整合版,包含stats和agents)
export const getMonitorDetail = (id: string) => {
return get<MonitorDetail>(`/monitors/${encodeURIComponent(id)}`);
};
// VictoriaMetrics 时序数据点
export interface MetricDataPoint {
timestamp: number; // 毫秒时间戳
@@ -0,0 +1,51 @@
interface AgentHealthIndicatorProps {
total: number;
stats: {
up: number;
down: number;
unknown: number;
};
}
export const AgentHealthIndicator = ({ total, stats }: AgentHealthIndicatorProps) => {
const dots = [];
// 生成 up 的绿点
for (let i = 0; i < stats.up; i++) {
dots.push(
<span
key={`up-${i}`}
className="inline-block h-2 w-2 rounded-full bg-emerald-500"
/>
);
}
// 生成 down 的红点
for (let i = 0; i < stats.down; i++) {
dots.push(
<span
key={`down-${i}`}
className="inline-block h-2 w-2 rounded-full bg-red-500"
/>
);
}
// 生成 unknown 的灰点
for (let i = 0; i < stats.unknown; i++) {
dots.push(
<span
key={`unknown-${i}`}
className="inline-block h-2 w-2 rounded-full bg-slate-400 dark:bg-slate-600"
/>
);
}
return (
<div className="flex items-center gap-1">
{dots}
<span className="ml-1 text-xs text-slate-500 dark:text-slate-400">
({stats.up}/{total})
</span>
</div>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CertBadgeProps {
expiryTime: number;
daysLeft: number;
}
export const CertBadge = ({ expiryTime, daysLeft }: CertBadgeProps) => {
if (!expiryTime || daysLeft === undefined) return null;
const isExpired = daysLeft < 0;
let colorClass = "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
if (isExpired) {
colorClass = "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
} else if (daysLeft < 30) {
colorClass = "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
}
return (
<div className={cn("flex items-center gap-1.5 px-2 py-1 rounded text-xs border", colorClass)}>
<ShieldCheck className="w-3 h-3" />
<span>{isExpired ? "已过期" : `${daysLeft} 天后过期`}</span>
</div>
);
};
+43
View File
@@ -0,0 +1,43 @@
import { Area, AreaChart, ResponsiveContainer } from 'recharts';
interface MiniChartProps {
data: Array<{ time: string; value: number }>;
status: string;
id: string;
}
export const MiniChart = ({ data, status, id }: MiniChartProps) => {
if (!data || data.length === 0) {
return (
<div className="h-16 w-full flex items-center justify-center text-xs text-slate-400 dark:text-slate-500">
暂无数据
</div>
);
}
const color = status === 'up' ? '#10b981' : status === 'down' ? '#f59e0b' : '#94a3b8';
return (
<div className="h-16 w-full -mb-2">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<defs>
<linearGradient id={`colorLatency-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.2} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
fillOpacity={1}
fill={`url(#colorLatency-${id})`}
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
@@ -0,0 +1,20 @@
import {formatTime} from '@/utils/util';
interface ResponseTimeRangeProps {
min: number;
avg: number;
max: number;
}
export const ResponseTimeRange = ({ min, avg, max }: ResponseTimeRangeProps) => {
return (
<div className="text-sm">
<span className="font-semibold text-slate-900 dark:text-white">
{formatTime(avg)}
</span>
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
(范围: {formatTime(min)}-{formatTime(max)})
</span>
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
import type {ReactNode} from 'react';
interface StatCardProps {
title: string;
value: string | number;
icon: ReactNode;
color?: string;
}
export const StatCard = ({title, value, icon, color = "text-white dark:text-white"}: StatCardProps) => {
console.log(`StatCard`, title, value, icon, color)
return (
<div
className="bg-white dark:bg-slate-800 p-5 rounded-xl border border-slate-200 dark:border-slate-700/50 flex items-center justify-between hover:shadow-md transition-shadow">
<div>
<p className="text-slate-600 dark:text-slate-400 text-sm font-medium mb-1">{title}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
</div>
<div
className="p-3 bg-slate-100 dark:bg-slate-700/50 rounded-lg border border-slate-200 dark:border-slate-600/50">
{icon}
</div>
</div>
);
};
@@ -0,0 +1,35 @@
import {cn} from '@/lib/utils';
interface StatusBadgeProps {
status: string;
}
export const StatusBadge = ({status}: StatusBadgeProps) => {
const styles = {
up: "bg-emerald-500/10 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20",
down: "bg-rose-500/10 dark:bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/20",
unknown: "bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700",
};
const labels = {
up: "正常",
down: "异常",
unknown: "未知",
};
const style = styles[status as keyof typeof styles] || styles.unknown;
const label = labels[status as keyof typeof labels] || labels.unknown;
return (
<span
className={cn("px-2.5 py-0.5 rounded-full text-xs font-medium border flex items-center gap-1.5 w-fit", style)}>
<span className={cn(
"w-1.5 h-1.5 rounded-full animate-pulse",
status === 'up' ? 'bg-emerald-500 dark:bg-emerald-400' :
status === 'down' ? 'bg-rose-500 dark:bg-rose-400' :
'bg-slate-400 dark:bg-slate-400'
)}/>
{label}
</span>
);
};
+21
View File
@@ -0,0 +1,21 @@
import { Globe, Server, ShieldCheck, Wifi } from 'lucide-react';
interface TypeIconProps {
type: string;
}
export const TypeIcon = ({ type }: TypeIconProps) => {
switch (type.toLowerCase()) {
case 'https':
return <ShieldCheck className="w-4 h-4 text-purple-500 dark:text-purple-400" />;
case 'http':
return <Globe className="w-4 h-4 text-blue-500 dark:text-blue-400" />;
case 'tcp':
return <Server className="w-4 h-4 text-orange-500 dark:text-orange-400" />;
case 'icmp':
case 'ping':
return <Wifi className="w-4 h-4 text-cyan-500 dark:text-cyan-400" />;
default:
return <Server className="w-4 h-4 text-slate-500 dark:text-slate-400" />;
}
};
+1 -7
View File
@@ -52,12 +52,6 @@ const EmptyState = ({message = '监控数据不存在'}: { message?: string }) =
);
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'},
@@ -317,7 +311,7 @@ const MonitorDetail = () => {
const navigate = useNavigate();
const {id} = useParams<{ id: string }>();
const [selectedAgent, setSelectedAgent] = useState<string>('all');
const [timeRange, setTimeRange] = useState<string>('15m');
const [timeRange, setTimeRange] = useState<string>('1d');
// 获取监控详情(聚合数据)
const {data: monitorDetail, isLoading} = useQuery<PublicMonitor>({
+326 -111
View File
@@ -1,13 +1,17 @@
import {useEffect} from 'react';
import {useEffect, useMemo, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {AlertCircle, CheckCircle2, Clock, Loader2, Shield} from 'lucide-react';
import {getPublicMonitors} from '@/api/monitor.ts';
import {AlertCircle, BarChart3, CheckCircle2, Clock, Loader2, Maximize2, Search, Server, Shield} from 'lucide-react';
import {type GetMetricsResponse, getMonitorHistory, getPublicMonitors} from '@/api/monitor.ts';
import type {PublicMonitor} from '@/types';
import {usePublicLayout} from '../PublicLayout';
import {cn} from '@/lib/utils';
import {renderCert} from "@/pages/Public/Monitor.tsx";
import {formatTime,} from "@/utils/util.ts"
import {formatDateTime} from "@/utils/util.ts";
import {StatCard} from '@/components/monitor/StatCard';
import {StatusBadge} from '@/components/monitor/StatusBadge';
import {TypeIcon} from '@/components/monitor/TypeIcon';
import {CertBadge} from '@/components/monitor/CertBadge';
import {AgentHealthIndicator} from '@/components/monitor/AgentHealthIndicator';
import {MiniChart} from '@/components/monitor/MiniChart';
const LoadingSpinner = () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
@@ -18,33 +22,6 @@ const LoadingSpinner = () => (
</div>
);
const StatusBadge = ({status}: { status: string }) => {
let containerClass = 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300';
let label = '未知';
let icon = <Clock className="h-3.5 w-3.5"/>;
if (status === 'up') {
containerClass = 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300';
label = '正常';
icon = <CheckCircle2 className="h-3.5 w-3.5"/>;
} else if (status === 'down') {
containerClass = 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300';
label = '异常';
icon = <AlertCircle className="h-3.5 w-3.5"/>;
}
return (
<div
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
containerClass
)}>
{icon}
{label}
</div>
);
};
const EmptyState = () => (
<div className="flex min-h-[400px] flex-col items-center justify-center text-slate-500 dark:text-slate-400">
<Shield className="mb-4 h-16 w-16 opacity-20"/>
@@ -53,17 +30,177 @@ const EmptyState = () => (
</div>
);
type DisplayMode = 'avg' | 'max';
type FilterStatus = 'all' | 'up' | 'down' | 'unknown';
// 监控卡片组件 - 单独抽出以便使用独立的历史数据查询
const MonitorCard = ({
monitor,
displayMode,
onClick
}: {
monitor: PublicMonitor;
displayMode: DisplayMode;
onClick: () => void;
}) => {
// 为每个监控卡片查询历史数据(15分钟)
const {data: historyData} = useQuery<GetMetricsResponse>({
queryKey: ['monitorHistory', monitor.id, '15m'],
queryFn: async () => {
const response = await getMonitorHistory(monitor.id, '1h');
return response.data;
},
refetchInterval: 60000, // 1分钟刷新一次
staleTime: 30000, // 30秒内认为数据是新鲜的
});
// 将 VictoriaMetrics 的时序数据转换为图表数据(使用平均值或最大值)
const chartData = useMemo(() => {
if (!historyData || !historyData.series || historyData.series.length === 0) {
return [];
}
// 创建一个时间戳到数据点的映射
const timeMap = new Map<number, number[]>();
// 遍历所有系列(每个探针一个系列)
historyData.series.forEach(series => {
if (series.data && series.data.length > 0) {
series.data.forEach(point => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, []);
}
timeMap.get(point.timestamp)!.push(point.value);
});
}
});
// 转换为图表数据格式,并计算聚合值
const result = Array.from(timeMap.entries())
.map(([timestamp, values]) => {
let aggregatedValue: number;
if (displayMode === 'avg') {
// 计算平均值
aggregatedValue = Math.round(values.reduce((a, b) => a + b, 0) / values.length);
} else {
// 使用最大值
aggregatedValue = Math.max(...values);
}
return {
time: new Date(timestamp).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}),
value: aggregatedValue,
};
});
return result;
}, [historyData, displayMode]);
const displayValue = displayMode === 'avg' ? monitor.responseTime : monitor.responseTimeMax;
const displayLabel = displayMode === 'avg' ? '平均延迟' : '最差节点延迟';
return (
<div
onClick={onClick}
className="group bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700/50 hover:border-blue-400 dark:hover:border-blue-500/50 transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/10 cursor-pointer overflow-hidden relative"
>
<div className="p-5">
{/* 头部:图标、名称、状态 */}
<div className="flex justify-between items-start mb-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div
className="p-2 rounded-lg bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 flex-shrink-0">
<TypeIcon type={monitor.type}/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
{monitor.name}
</h3>
<p className="text-xs text-slate-500 dark:text-slate-500 font-mono mt-0.5 truncate">
{monitor.showTargetPublic ? monitor.target : '******'}
</p>
</div>
</div>
<div className="flex-shrink-0 ml-2">
<StatusBadge status={monitor.status}/>
</div>
</div>
{/* 探针健康度 */}
{monitor.agentStats && monitor.agentCount > 0 && (
<div className="mb-4">
<AgentHealthIndicator
total={monitor.agentCount}
stats={monitor.agentStats}
/>
</div>
)}
{/* 指标信息 */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-500 mb-1 flex items-center gap-1">
{displayLabel}
{monitor.agentCount > 0 && (
<span
className="bg-slate-200 dark:bg-slate-700 text-[10px] px-1.5 rounded-full text-slate-600 dark:text-slate-300">
{monitor.agentCount} 节点
</span>
)}
</p>
<p className={cn(
"text-xl font-bold flex items-end gap-1",
displayValue > 200 ? 'text-amber-600 dark:text-amber-400' : 'text-slate-900 dark:text-slate-200'
)}>
{displayValue} <span
className="text-xs font-normal text-slate-500 dark:text-slate-500 mb-1">ms</span>
</p>
</div>
<div>
{monitor.type === 'https' && monitor.certExpiryTime ? (
<>
<p className="text-xs text-slate-500 dark:text-slate-500 mb-1">SSL 证书</p>
<CertBadge
expiryTime={monitor.certExpiryTime}
daysLeft={monitor.certDaysLeft}
/>
</>
) : (
<>
<p className="text-xs text-slate-500 dark:text-slate-500 mb-1">上次检测</p>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{formatDateTime(monitor.lastCheckTime)}
</p>
</>
)}
</div>
</div>
{/* 迷你走势图 */}
<MiniChart
data={chartData}
status={monitor.status}
id={monitor.id}
/>
</div>
</div>
);
};
interface Stats {
total: number;
online: number;
issues: number;
avgLatency: number;
}
const MonitorList = () => {
const navigate = useNavigate();
const {viewMode, setShowViewToggle} = usePublicLayout();
const [filter, setFilter] = useState<FilterStatus>('all');
const [searchKeyword, setSearchKeyword] = useState('');
const [displayMode, setDisplayMode] = useState<DisplayMode>('max');
// 挂载时启用视图切换,卸载时禁用
useEffect(() => {
setShowViewToggle(true);
return () => setShowViewToggle(false);
}, [setShowViewToggle]);
const {data: monitors = [], isLoading, dataUpdatedAt} = useQuery<PublicMonitor[]>({
const {data: monitors = [], isLoading} = useQuery<PublicMonitor[]>({
queryKey: ['publicMonitors'],
queryFn: async () => {
const response = await getPublicMonitors();
@@ -72,74 +209,45 @@ const MonitorList = () => {
refetchInterval: 30000, // 30秒刷新一次
});
const monitorSummaries = monitors;
let [stats, setStats] = useState<Stats>();
const renderListView = () => (
<>
{/* 桌面端:使用表格布局 */}
<div className="hidden overflow-hidden rounded-md border border-slate-200 dark:border-slate-700 lg:block">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700 text-sm">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-slate-700 dark:text-slate-300">
<th className="px-5 py-3">监控项</th>
<th className="px-5 py-3">状态</th>
<th className="px-5 py-3">当前响应</th>
<th className="px-5 py-3">证书信息</th>
</tr>
</thead>
<tbody
className="divide-y divide-slate-200 dark:divide-slate-700 text-slate-700 dark:text-slate-200">
{monitorSummaries.map((stats) => {
return (
<tr
key={stats.id}
tabIndex={0}
onClick={() => navigate(`/monitors/${encodeURIComponent(stats.id)}`)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
navigate(`/monitors/${encodeURIComponent(stats.id)}`);
}
}}
className="cursor-pointer transition hover:bg-slate-50 dark:hover:bg-slate-800/50 focus-within:bg-slate-50 dark:focus-within:bg-slate-800/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-200"
>
<td className="px-5 py-4 align-center">
<div>
<div className="font-semibold text-slate-900 dark:text-white">
{stats.name}
</div>
<div className="mt-1 text-xs text-blue-700 dark:text-blue-400 break-all">
{stats.target}
</div>
{stats.agentCount > 1 && (
<div className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{stats.agentCount} 个探针
</div>
)}
</div>
</td>
<td className="px-5 py-4 align-center">
<StatusBadge status={stats.status}/>
</td>
<td className="px-5 py-4 align-center">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-slate-400 dark:text-slate-500"/>
<span className="text-sm font-semibold text-slate-900 dark:text-white">
{formatTime(stats.responseTime)}
</span>
</div>
</td>
<td className="px-5 py-4 align-center">
{renderCert(stats)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
// 过滤和搜索
const filteredMonitors = useMemo(() => {
let result = monitors;
// 状态过滤
if (filter !== 'all') {
result = result.filter(m => m.status === filter);
}
// 搜索过滤
if (searchKeyword.trim()) {
const keyword = searchKeyword.toLowerCase();
result = result.filter(m =>
m.name.toLowerCase().includes(keyword) ||
m.target.toLowerCase().includes(keyword)
);
}
return result;
}, [monitors, filter, searchKeyword]);
// 统计信息
const calculateStats = (monitors: PublicMonitor[]) => {
const total = monitors.length;
const online = monitors.filter(m => m.status === 'up').length;
const issues = total - online;
const avgLatency = total > 0
? Math.round(monitors.reduce((acc, curr) => acc + curr.responseTime, 0) / total)
: 0;
return {total, online, issues, avgLatency};
}
useEffect(() => {
let stats = calculateStats(monitors);
setStats(stats);
}, [monitors]);
console.log(`stats`, stats)
if (isLoading) {
return (
@@ -150,10 +258,117 @@ const MonitorList = () => {
}
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{monitorSummaries.length === 0 ? (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<StatCard
title="监控服务总数"
value={stats?.total}
icon={<Server className="w-5 h-5 text-slate-400"/>}
color="text-gray-700 dark:text-gray-400"
/>
<StatCard
title="系统正常"
value={stats?.online}
icon={<CheckCircle2 className="w-5 h-5 text-emerald-500 dark:text-emerald-400"/>}
color="text-emerald-700 dark:text-emerald-400"
/>
<StatCard
title="异常服务"
value={stats?.issues}
icon={<AlertCircle className="w-5 h-5 text-rose-500 dark:text-rose-400"/>}
color={stats?.issues > 0 ? "text-rose-700 dark:text-rose-400" : "text-slate-600 dark:text-slate-400"}
/>
<StatCard
title="全局平均延迟"
value={`${stats?.avgLatency}ms`}
icon={<Clock className="w-5 h-5 text-blue-500 dark:text-blue-400"/>}
color="text-gray-700 dark:text-gray-400"
/>
</div>
{/* 过滤和搜索 */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
<div className="flex flex-wrap gap-4 items-center w-full md:w-auto">
{/* 状态过滤 */}
<div
className="flex gap-2 bg-slate-100 dark:bg-slate-800 p-1 rounded-lg border border-slate-200 dark:border-slate-700">
{(['all', 'up', 'down', 'unknown'] as const).map(f => {
const labels = {all: '全部', up: '正常', down: '异常', unknown: '未知'};
return (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
"px-4 py-1.5 rounded-md text-xs font-medium transition-all",
filter === f
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-white shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
)}
>
{labels[f]}
</button>
);
})}
</div>
{/* 显示模式切换 */}
<div
className="flex gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-lg border border-slate-200 dark:border-slate-700 items-center">
<span className="text-xs text-slate-500 dark:text-slate-500 px-2">卡片指标:</span>
<button
onClick={() => setDisplayMode('avg')}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded transition-all flex items-center gap-1",
displayMode === 'avg'
? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
)}
>
<BarChart3 className="w-3 h-3"/> 平均
</button>
<button
onClick={() => setDisplayMode('max')}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded transition-all flex items-center gap-1",
displayMode === 'max'
? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
)}
>
<Maximize2 className="w-3 h-3"/> 最差(Max)
</button>
</div>
</div>
{/* 搜索框 */}
<div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500"/>
<input
type="text"
placeholder="搜索服务名称或地址..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg pl-9 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-slate-900 dark:text-slate-200"
/>
</div>
</div>
{/* 监控卡片列表 */}
{filteredMonitors.length === 0 ? (
<EmptyState/>
) : renderListView()}
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredMonitors.map(monitor => (
<MonitorCard
key={monitor.id}
monitor={monitor}
displayMode={displayMode}
onClick={() => navigate(`/monitors/${encodeURIComponent(monitor.id)}`)}
/>
))}
</div>
)}
</div>
);
};
+35
View File
@@ -296,11 +296,46 @@ export interface PublicMonitor {
status: string;
responseTime: number;
responseTimeMin: number;
responseTimeMax: number;
certExpiryTime: number;
certDaysLeft: number;
agentStats: {
up: number;
down: number;
unknown: number;
};
lastCheckTime: number;
}
// 监控详情(整合版)
export interface MonitorDetail {
id: string;
name: string;
type: 'http' | 'https' | 'tcp' | 'icmp' | 'ping';
target: string;
showTargetPublic: boolean;
description?: string;
enabled: boolean;
interval: number;
stats: {
status: string;
responseTime: number;
responseTimeMin: number;
responseTimeMax: number;
lastCheckTime: number;
agentCount: number;
agentStats: {
up: number;
down: number;
unknown: number;
};
certExpiryTime: number;
certDaysLeft: number;
};
agents: AgentMonitorStat[];
}
// 监控统计数据
export interface CertStats {
certExpiryTime: number;