diff --git a/internal/app.go b/internal/app.go index d48a8be..00c7fab 100644 --- a/internal/app.go +++ b/internal/app.go @@ -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) diff --git a/internal/handler/monitor_handler.go b/internal/handler/monitor_handler.go index f210f9b..6c1812c 100644 --- a/internal/handler/monitor_handler.go +++ b/internal/handler/monitor_handler.go @@ -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") diff --git a/internal/metric/monitor.go b/internal/metric/monitor.go index c5c9965..28d4b67 100644 --- a/internal/metric/monitor.go +++ b/internal/metric/monitor.go @@ -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"` } diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index f6fbbe8..ce4a7b4 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -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" } diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index d8e7a74..f5c1e26 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -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) { // 查询数据库 diff --git a/web/src/api/monitor.ts b/web/src/api/monitor.ts index a5265f4..580a11a 100644 --- a/web/src/api/monitor.ts +++ b/web/src/api/monitor.ts @@ -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(`/monitors/${encodeURIComponent(id)}/agents`); }; +// 公开接口 - 获取监控详情(整合版,包含stats和agents) +export const getMonitorDetail = (id: string) => { + return get(`/monitors/${encodeURIComponent(id)}`); +}; + // VictoriaMetrics 时序数据点 export interface MetricDataPoint { timestamp: number; // 毫秒时间戳 diff --git a/web/src/components/monitor/AgentHealthIndicator.tsx b/web/src/components/monitor/AgentHealthIndicator.tsx new file mode 100644 index 0000000..e4663c6 --- /dev/null +++ b/web/src/components/monitor/AgentHealthIndicator.tsx @@ -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( + + ); + } + + // 生成 down 的红点 + for (let i = 0; i < stats.down; i++) { + dots.push( + + ); + } + + // 生成 unknown 的灰点 + for (let i = 0; i < stats.unknown; i++) { + dots.push( + + ); + } + + return ( +
+ {dots} + + ({stats.up}/{total}) + +
+ ); +}; diff --git a/web/src/components/monitor/CertBadge.tsx b/web/src/components/monitor/CertBadge.tsx new file mode 100644 index 0000000..75e4f9e --- /dev/null +++ b/web/src/components/monitor/CertBadge.tsx @@ -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 ( +
+ + {isExpired ? "已过期" : `${daysLeft} 天后过期`} +
+ ); +}; diff --git a/web/src/components/monitor/MiniChart.tsx b/web/src/components/monitor/MiniChart.tsx new file mode 100644 index 0000000..b85693d --- /dev/null +++ b/web/src/components/monitor/MiniChart.tsx @@ -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 ( +
+ 暂无数据 +
+ ); + } + + const color = status === 'up' ? '#10b981' : status === 'down' ? '#f59e0b' : '#94a3b8'; + + return ( +
+ + + + + + + + + + + +
+ ); +}; diff --git a/web/src/components/monitor/ResponseTimeRange.tsx b/web/src/components/monitor/ResponseTimeRange.tsx new file mode 100644 index 0000000..0771d7b --- /dev/null +++ b/web/src/components/monitor/ResponseTimeRange.tsx @@ -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 ( +
+ + {formatTime(avg)} + + + (范围: {formatTime(min)}-{formatTime(max)}) + +
+ ); +}; diff --git a/web/src/components/monitor/StatCard.tsx b/web/src/components/monitor/StatCard.tsx new file mode 100644 index 0000000..c72576b --- /dev/null +++ b/web/src/components/monitor/StatCard.tsx @@ -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 ( +
+
+

{title}

+

{value}

+
+
+ {icon} +
+
+ ); +}; diff --git a/web/src/components/monitor/StatusBadge.tsx b/web/src/components/monitor/StatusBadge.tsx new file mode 100644 index 0000000..14e1e13 --- /dev/null +++ b/web/src/components/monitor/StatusBadge.tsx @@ -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 ( + + + {label} + + ); +}; diff --git a/web/src/components/monitor/TypeIcon.tsx b/web/src/components/monitor/TypeIcon.tsx new file mode 100644 index 0000000..e91cf98 --- /dev/null +++ b/web/src/components/monitor/TypeIcon.tsx @@ -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 ; + case 'http': + return ; + case 'tcp': + return ; + case 'icmp': + case 'ping': + return ; + default: + return ; + } +}; diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index ad3f474..cb92465 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -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('all'); - const [timeRange, setTimeRange] = useState('15m'); + const [timeRange, setTimeRange] = useState('1d'); // 获取监控详情(聚合数据) const {data: monitorDetail, isLoading} = useQuery({ diff --git a/web/src/pages/Public/MonitorList.tsx b/web/src/pages/Public/MonitorList.tsx index e99f889..1ed84b2 100644 --- a/web/src/pages/Public/MonitorList.tsx +++ b/web/src/pages/Public/MonitorList.tsx @@ -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 = () => (
@@ -18,33 +22,6 @@ const LoadingSpinner = () => (
); -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 = ; - - if (status === 'up') { - containerClass = 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'; - label = '正常'; - icon = ; - } else if (status === 'down') { - containerClass = 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300'; - label = '异常'; - icon = ; - } - - return ( -
- {icon} - {label} -
- ); -}; - const EmptyState = () => (
@@ -53,17 +30,177 @@ const EmptyState = () => (
); +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({ + 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(); + + // 遍历所有系列(每个探针一个系列) + 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 ( +
+
+ {/* 头部:图标、名称、状态 */} +
+
+
+ +
+
+

+ {monitor.name} +

+

+ {monitor.showTargetPublic ? monitor.target : '******'} +

+
+
+
+ +
+
+ + {/* 探针健康度 */} + {monitor.agentStats && monitor.agentCount > 0 && ( +
+ +
+ )} + + {/* 指标信息 */} +
+
+

+ {displayLabel} + {monitor.agentCount > 0 && ( + + {monitor.agentCount} 节点 + + )} +

+

200 ? 'text-amber-600 dark:text-amber-400' : 'text-slate-900 dark:text-slate-200' + )}> + {displayValue} ms +

+
+
+ {monitor.type === 'https' && monitor.certExpiryTime ? ( + <> +

SSL 证书

+ + + ) : ( + <> +

上次检测

+

+ {formatDateTime(monitor.lastCheckTime)} +

+ + )} +
+
+ + {/* 迷你走势图 */} + +
+
+ ); +}; + +interface Stats { + total: number; + online: number; + issues: number; + avgLatency: number; +} + const MonitorList = () => { const navigate = useNavigate(); - const {viewMode, setShowViewToggle} = usePublicLayout(); + const [filter, setFilter] = useState('all'); + const [searchKeyword, setSearchKeyword] = useState(''); + const [displayMode, setDisplayMode] = useState('max'); - // 挂载时启用视图切换,卸载时禁用 - useEffect(() => { - setShowViewToggle(true); - return () => setShowViewToggle(false); - }, [setShowViewToggle]); - - const {data: monitors = [], isLoading, dataUpdatedAt} = useQuery({ + const {data: monitors = [], isLoading} = useQuery({ queryKey: ['publicMonitors'], queryFn: async () => { const response = await getPublicMonitors(); @@ -72,74 +209,45 @@ const MonitorList = () => { refetchInterval: 30000, // 30秒刷新一次 }); - const monitorSummaries = monitors; + let [stats, setStats] = useState(); - const renderListView = () => ( - <> - {/* 桌面端:使用表格布局 */} -
- - - - - - - - - - - {monitorSummaries.map((stats) => { - return ( - 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" - > - - - - - - ); - })} - -
监控项状态当前响应证书信息
-
-
- {stats.name} -
-
- {stats.target} -
- {stats.agentCount > 1 && ( -
- {stats.agentCount} 个探针 -
- )} -
-
- - -
- - - {formatTime(stats.responseTime)} - -
-
- {renderCert(stats)} -
-
- - ); + // 过滤和搜索 + 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 ( -
- {monitorSummaries.length === 0 ? ( +
+ {/* 统计卡片 */} +
+ } + color="text-gray-700 dark:text-gray-400" + /> + } + color="text-emerald-700 dark:text-emerald-400" + /> + } + color={stats?.issues > 0 ? "text-rose-700 dark:text-rose-400" : "text-slate-600 dark:text-slate-400"} + /> + } + color="text-gray-700 dark:text-gray-400" + /> +
+ + {/* 过滤和搜索 */} +
+
+ {/* 状态过滤 */} +
+ {(['all', 'up', 'down', 'unknown'] as const).map(f => { + const labels = {all: '全部', up: '正常', down: '异常', unknown: '未知'}; + return ( + + ); + })} +
+ + {/* 显示模式切换 */} +
+ 卡片指标: + + +
+
+ + {/* 搜索框 */} +
+ + 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" + /> +
+
+ + {/* 监控卡片列表 */} + {filteredMonitors.length === 0 ? ( - ) : renderListView()} + ) : ( +
+ {filteredMonitors.map(monitor => ( + navigate(`/monitors/${encodeURIComponent(monitor.id)}`)} + /> + ))} +
+ )}
); }; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 3acd332..01d95a5 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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;