Files
pika/portal/src/pages/MonitorList.tsx
T
dushixiang 1dd07117a3 refactor: move portal to repo root, merge admin into web/
目录结构调整,为 portal 搬迁到独立仓库做准备:

- portal/ 从 web/portal/ 移到仓库根目录,完全自包含
- web/admin/ 内容合并到 web/ 根目录(admin 就是主仓库的前端)
- web/scripts/ 保留在主仓库(负责发布时组装 admin + portal 产物)
- admin 构建产物改为输出到 web/.dist,避免与最终 web/dist 冲突
- 更新 Makefile、assemble-web.mjs、paths.go、README 中的路径引用
2026-08-09 22:56:16 +08:00

432 lines
19 KiB
TypeScript

import {useEffect, useMemo, useState} from 'react';
import {Link} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {AlertTriangle, BarChart3, CheckCircle2, Globe, Loader2, Maximize2, Search, Server, Shield, ShieldCheck, Wifi, Zap} from 'lucide-react';
import {Area, AreaChart, ResponsiveContainer} from 'recharts';
import {pika} from '../api';
import type {MetricsResponse, PublicMonitor} from '../types';
import {cn, formatDateTime} from '../lib/utils';
import {Card, StatBlock, StatusBadge} from '../components';
/* ========================================== TypeIcon ========================================== */
const TypeIcon = ({type}: {type: string}) => {
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-500"/>;
default:
return <Server className="w-4 h-4 text-slate-500 dark:text-slate-400"/>;
}
};
/* ========================================== CertBadge ========================================== */
const CertBadge = ({expiryTime, daysLeft}: {expiryTime: number; daysLeft: number}) => {
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>
);
};
/* ========================================== MonitorCard ========================================== */
export type DisplayMode = 'avg' | 'max';
const MiniChart = ({data, lastValue, id}: {
data: Array<{timestamp: number; value: number}>;
lastValue?: number;
id: string;
}) => {
const chartData = useMemo(() => [...data].sort((a, b) => a.timestamp - b.timestamp), [data]);
if (chartData.length === 0) {
return <div className="flex h-16 w-full items-center justify-center text-xs text-slate-400 dark:text-slate-500">暂无数据</div>;
}
const color = lastValue && lastValue <= 200 ? '#22d3ee' : '#fbbf24';
return (
<div className="-mb-2 h-16 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id={`colorLatency-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3}/>
<stop offset="100%" stopColor={color} stopOpacity={0}/>
</linearGradient>
<filter id={`glow-${id}`} height="300%" width="300%" x="-75%" y="-75%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
fill={`url(#colorLatency-${id})`}
strokeWidth={2}
filter={`url(#glow-${id})`}
isAnimationActive={false}
connectNulls
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
const MonitorCard = ({monitor, displayMode}: {
monitor: PublicMonitor;
displayMode: DisplayMode;
}) => {
// 为每个监控卡片查询历史数据
const {data: historyData} = useQuery<MetricsResponse>({
queryKey: ['monitorHistory', monitor.id, '12h'], // 对应后端 60 秒步长
queryFn: async () => {
return pika.getMonitorHistory(monitor.id, {range: '1h'});
},
refetchInterval: 60000,
staleTime: 30000,
});
// 转换时序数据为图表数据 - 使用统一格点对该对齐多探针数据
const chartData = useMemo(() => {
if (!historyData?.series || historyData.series.length === 0) {
return [];
}
const validSeries = historyData.series.filter(s => s.data && s.data.length > 0);
if (validSeries.length === 0) return [];
// 确定全局时间范围
let minTime = Infinity, maxTime = -Infinity;
validSeries.forEach(s => {
minTime = Math.min(minTime, s.data![0].timestamp);
maxTime = Math.max(maxTime, s.data![s.data!.length - 1].timestamp);
});
if (minTime >= maxTime) return [];
// 定义目标采集点 (1小时数据,建议 60 个采集点)
const maxPoints = 60;
const timeStep = (maxTime - minTime) / (maxPoints - 1);
const targetTimestamps: number[] = [];
for (let i = 0; i < maxPoints; i++) {
targetTimestamps.push(minTime + i * timeStep);
}
// 线性插值函数
const interpolate = (data: Array<{ timestamp: number; value: number }>, targetTime: number): number | null => {
if (data.length === 0) return null;
if (data.length === 1) return data[0].timestamp === targetTime ? data[0].value : null;
if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) return null;
let left = 0, right = data.length - 1;
while (right - left > 1) {
const mid = Math.floor((left + right) / 2);
if (data[mid].timestamp <= targetTime) left = mid;
else right = mid;
}
const leftPoint = data[left];
const rightPoint = data[right];
const ratio = (targetTime - leftPoint.timestamp) / (rightPoint.timestamp - leftPoint.timestamp);
return leftPoint.value + ratio * (rightPoint.value - leftPoint.value);
};
// 对每个目标时间点,计算所有探针的聚合值
return targetTimestamps.map(timestamp => {
const values: number[] = [];
validSeries.forEach(s => {
const val = interpolate(s.data!, timestamp);
if (val !== null) values.push(val);
});
if (values.length === 0) return { timestamp, value: 0 };
return {
timestamp,
value: displayMode === 'avg'
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
: Math.max(...values),
};
});
}, [historyData, displayMode]);
const displayValue = displayMode === 'avg' ? monitor.responseTime : monitor.responseTimeMax;
const displayLabel = displayMode === 'avg' ? '平均延迟' : '最差节点延迟';
return (
<Card className={'p-5'} interactive>
{/* 头部 */}
<div className="flex justify-between items-start mb-4">
<div className="flex gap-3 flex-1 min-w-0">
<div
className="p-2.5 bg-gray-100 dark:bg-cyan-950/30 border border-slate-200 dark:border-cyan-500/20 rounded-lg flex-shrink-0">
<TypeIcon type={monitor.type}/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-sm text-slate-800 dark:text-cyan-100 tracking-wide truncate group-hover:text-cyan-500 transition-colors">
{monitor.name}
</h3>
<div className="text-xs font-mono text-gray-600 dark:text-cyan-500/80 mb-0.5 tracking-wider truncate">
{monitor.target}
</div>
</div>
</div>
<div className="flex-shrink-0 ml-2">
<StatusBadge status={monitor.status}/>
</div>
</div>
{/* 指标信息 */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-xs text-gray-600 dark:text-cyan-500 mb-1 flex items-center gap-1">
{displayLabel}
{monitor.agentCount > 0 && (
<span
className="bg-slate-200 dark:bg-slate-700 text-xs px-1.5 rounded-full text-slate-700 dark:text-cyan-300">
{monitor.agentCount} 节点
</span>
)}
</p>
<div
className={`text-xl font-bold flex items-baseline gap-1 ${displayValue > 200 ? 'text-amber-600 dark:text-amber-400 drop-shadow-[0_0_8px_rgba(251,191,36,0.3)] dark:drop-shadow-[0_0_8px_rgba(251,191,36,0.5)]' : 'text-slate-800 dark:text-white drop-shadow-none dark:drop-shadow-[0_0_8px_rgba(34,211,238,0.5)]'}`}>
{displayValue}<span className="text-xs text-gray-600 dark:text-cyan-500 font-normal">ms</span>
</div>
</div>
<div>
{monitor.type === 'https' && monitor.certExpiryTime ? (
<>
<p className="text-xs text-gray-600 dark:text-cyan-500 mb-1">SSL 证书</p>
<CertBadge
expiryTime={monitor.certExpiryTime}
daysLeft={monitor.certDaysLeft}
/>
</>
) : (
<>
<p className="text-xs text-gray-600 dark:text-cyan-500 mb-1">上次检测</p>
<p className="md:text-sm text-xs text-gray-700 dark:text-cyan-300 font-mono">
{formatDateTime(monitor.lastCheckTime)}
</p>
</>
)}
</div>
</div>
{/* 迷你走势图 */}
<MiniChart
data={chartData}
lastValue={displayValue}
id={monitor.id}
/>
</Card>
);
};
/* ========================================== MonitorList 本地组件 ========================================== */
const MonitorListSpinner = () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-3 text-gray-600 dark:text-cyan-500">
<Loader2 className="h-8 w-8 animate-spin text-gray-600 dark:text-cyan-500"/>
<span className="text-sm font-mono">加载监控数据中...</span>
</div>
</div>
);
const MonitorListEmpty = () => (
<div className="flex min-h-[400px] flex-col items-center justify-center text-gray-600 dark:text-cyan-500">
<Shield className="mb-4 h-16 w-16 opacity-20"/>
<p className="text-lg font-medium font-mono">暂无监控数据</p>
<p className="mt-2 text-sm text-gray-600 dark:text-cyan-500">请先在管理后台添加监控任务</p>
</div>
);
interface Stats {
total: number;
online: number;
issues: number;
avgLatency: number;
}
/* ========================================== MonitorList ========================================== */
const MonitorList = () => {
const [searchKeyword, setSearchKeyword] = useState('');
const [displayMode, setDisplayMode] = useState<DisplayMode>('max');
const {data: monitors = [], isLoading} = useQuery<PublicMonitor[]>({
queryKey: ['publicMonitors'],
queryFn: () => pika.listMonitors<PublicMonitor>(),
refetchInterval: 30000,
});
let [stats, setStats] = useState<Stats>();
// 过滤和搜索
const filteredMonitors = useMemo(() => {
let result = [...monitors];
// 搜索过滤
if (searchKeyword.trim()) {
const keyword = searchKeyword.toLowerCase();
result = result.filter(m =>
m.name.toLowerCase().includes(keyword) ||
m.target.toLowerCase().includes(keyword)
);
}
return result.sort((a, b) => Number(a.status !== 'up') - Number(b.status !== 'up'));
}, [monitors, 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]);
if (isLoading) {
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
<MonitorListSpinner/>
</div>
);
}
return (
<div className="mx-auto max-w-7xl px-3 sm:px-6 lg:px-8 py-4 sm:py-8 space-y-4 sm:space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 sm:gap-4">
<StatBlock
title="监控服务总数"
value={stats?.total}
icon={Globe}
color="cyan"
/>
<StatBlock
title="系统正常"
value={stats?.online}
icon={CheckCircle2}
color="emerald"
glow
/>
<StatBlock
title="异常服务"
value={stats?.issues}
icon={AlertTriangle}
color="rose"
alert={stats?.issues > 0}
/>
<StatBlock
title="全局平均延迟"
value={stats?.avgLatency}
unit={'ms'}
icon={Zap}
color="blue"
/>
</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-1 bg-slate-100 dark:bg-black/40 p-1 rounded-lg border border-slate-200 dark:border-cyan-900/50 items-center">
<span className="text-xs text-gray-600 dark:text-cyan-500 px-2 font-mono">卡片指标:</span>
<button
onClick={() => setDisplayMode('avg')}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded transition-all flex items-center gap-1 font-mono cursor-pointer",
displayMode === 'avg'
? 'bg-gray-200 dark:bg-cyan-500/20 text-gray-800 dark:text-cyan-300 border border-gray-300 dark:border-cyan-500/30'
: 'text-gray-600 dark:text-cyan-500 hover:text-gray-800 dark:hover:text-cyan-400'
)}
>
<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 font-mono cursor-pointer",
displayMode === 'max'
? 'bg-gray-200 dark:bg-cyan-500/20 text-gray-800 dark:text-cyan-300 border border-gray-300 dark:border-cyan-500/30'
: 'text-gray-600 dark:text-cyan-500 hover:text-gray-800 dark:hover:text-cyan-400'
)}
>
<Maximize2 className="w-3 h-3"/> 最差(Max)
</button>
</div>
</div>
{/* 搜索框 */}
<div className="relative w-full md:w-64 group">
<div
className="hidden dark:block absolute -inset-0.5 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-lg blur opacity-20 group-hover:opacity-40 transition duration-500"></div>
<div className="relative flex items-center bg-white dark:bg-[#0a0b10] rounded-lg border border-slate-200 dark:border-cyan-900">
<Search className="w-4 h-4 ml-3 text-gray-500 dark:text-cyan-500"/>
<input
type="text"
placeholder="搜索服务名称或地址..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-full bg-transparent border-none text-xs text-gray-800 dark:text-cyan-100 p-2.5 focus:ring-0 placeholder-gray-400 dark:placeholder-cyan-600 font-mono focus:outline-none"
/>
</div>
</div>
</div>
{/* 监控卡片列表 */}
{filteredMonitors.length === 0 ? (
<MonitorListEmpty/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 md:gap-4 gap-2">
{filteredMonitors.map(monitor => (
<Link key={monitor.id} to={`/monitors/${monitor.id}`}>
<MonitorCard
monitor={monitor}
displayMode={displayMode}
/>
</Link>
))}
</div>
)}
</div>
);
};
export default MonitorList;