优化服务监控
This commit is contained in:
1 parent
623cbecfb4
commit
f66c82e56a
16 files changed
+719
-181
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
// 查询数据库
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />;
|
||||
}
|
||||
};
|
||||
@@ -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>({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user