1
0

SystemHistoryModal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import type { ReactNode } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import { Modal, Select, Tabs } from 'antd';
  5. import {
  6. ApiOutlined,
  7. DashboardOutlined,
  8. DatabaseOutlined,
  9. DeploymentUnitOutlined,
  10. GlobalOutlined,
  11. HddOutlined,
  12. LineChartOutlined,
  13. PieChartOutlined,
  14. TeamOutlined,
  15. } from '@ant-design/icons';
  16. import { HttpUtil, SizeFormatter } from '@/utils';
  17. import { Sparkline } from '@/components/viz';
  18. import { useMediaQuery } from '@/hooks/useMediaQuery';
  19. import type { Status } from '@/models/status';
  20. import './SystemHistoryModal.css';
  21. interface SystemHistoryModalProps {
  22. open: boolean;
  23. status: Status;
  24. onClose: () => void;
  25. }
  26. interface MetricDef {
  27. key: string;
  28. tab: string;
  29. tabKey?: string;
  30. title: string;
  31. icon: ReactNode;
  32. valueMax: number | null;
  33. unit: string;
  34. stroke: string;
  35. key2?: string;
  36. stroke2?: string;
  37. name1?: string;
  38. name2?: string;
  39. key3?: string;
  40. stroke3?: string;
  41. name3?: string;
  42. }
  43. const METRICS: MetricDef[] = [
  44. {
  45. key: 'cpu',
  46. tab: 'CPU',
  47. tabKey: 'pages.index.cpu',
  48. title: 'pages.index.historyTitleCpu',
  49. icon: <DashboardOutlined />,
  50. valueMax: 100,
  51. unit: '%',
  52. stroke: '',
  53. },
  54. {
  55. key: 'mem',
  56. tab: 'RAM',
  57. tabKey: 'pages.index.memory',
  58. title: 'pages.index.historyTitleMem',
  59. icon: <DatabaseOutlined />,
  60. valueMax: 100,
  61. unit: '%',
  62. stroke: '#7c4dff',
  63. key2: 'swap',
  64. stroke2: '#ffa940',
  65. name1: 'pages.index.memory',
  66. name2: 'pages.index.swap',
  67. },
  68. {
  69. key: 'netUp',
  70. tab: 'Bandwidth',
  71. tabKey: 'pages.index.historyTabBandwidth',
  72. title: 'pages.index.historyTitleNetwork',
  73. icon: <GlobalOutlined />,
  74. valueMax: null,
  75. unit: 'B/s',
  76. stroke: '#1890ff',
  77. key2: 'netDown',
  78. stroke2: '#13c2c2',
  79. name1: 'Up',
  80. name2: 'Down',
  81. },
  82. {
  83. key: 'pktUp',
  84. tab: 'Packets',
  85. tabKey: 'pages.index.historyTabPackets',
  86. title: 'pages.index.historyTitlePackets',
  87. icon: <DeploymentUnitOutlined />,
  88. valueMax: null,
  89. unit: 'pkt/s',
  90. stroke: '#2f54eb',
  91. key2: 'pktDown',
  92. stroke2: '#36cfc9',
  93. name1: 'Up',
  94. name2: 'Down',
  95. },
  96. {
  97. key: 'tcpCount',
  98. tab: 'Connections',
  99. tabKey: 'pages.index.historyTabConnections',
  100. title: 'pages.index.historyTitleConnections',
  101. icon: <ApiOutlined />,
  102. valueMax: null,
  103. unit: '',
  104. stroke: '#597ef7',
  105. key2: 'udpCount',
  106. stroke2: '#73d13d',
  107. name1: 'TCP',
  108. name2: 'UDP',
  109. },
  110. {
  111. key: 'diskRead',
  112. tab: 'Disk I/O',
  113. tabKey: 'pages.index.historyTabDisk',
  114. title: 'pages.index.historyTitleDisk',
  115. icon: <HddOutlined />,
  116. valueMax: null,
  117. unit: 'B/s',
  118. stroke: '#eb2f96',
  119. key2: 'diskWrite',
  120. stroke2: '#722ed1',
  121. name1: 'Read',
  122. name2: 'Write',
  123. },
  124. {
  125. key: 'diskUsage',
  126. tab: 'Disk Usage',
  127. tabKey: 'pages.index.historyTabDiskUsage',
  128. title: 'pages.index.historyTitleDiskUsage',
  129. icon: <PieChartOutlined />,
  130. valueMax: 100,
  131. unit: '%',
  132. stroke: '#13c2c2',
  133. },
  134. {
  135. key: 'online',
  136. tab: 'Online',
  137. tabKey: 'pages.index.historyTabOnline',
  138. title: 'pages.index.historyTitleOnline',
  139. icon: <TeamOutlined />,
  140. valueMax: null,
  141. unit: '',
  142. stroke: '#52c41a',
  143. },
  144. {
  145. key: 'load1',
  146. tab: 'Load',
  147. tabKey: 'pages.index.historyTabLoad',
  148. title: 'pages.index.historyTitleLoad',
  149. icon: <LineChartOutlined />,
  150. valueMax: null,
  151. unit: '',
  152. stroke: '#fa8c16',
  153. key2: 'load5',
  154. stroke2: '#f5222d',
  155. name1: '1m',
  156. name2: '5m',
  157. key3: 'load15',
  158. stroke3: '#a0d911',
  159. name3: '15m',
  160. },
  161. ];
  162. function unitFormatter(unit: string, activeKey: string): (v: number) => string {
  163. if (unit === 'B/s') {
  164. return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0)).replace(/\.\d+/, '')}/s`;
  165. }
  166. if (unit === 'pkt/s') {
  167. return (v) => `${Math.round(Math.max(0, Number(v) || 0)).toLocaleString()}/s`;
  168. }
  169. if (unit === '%') {
  170. return (v) => `${Number(v).toFixed(1)}%`;
  171. }
  172. return (v) => {
  173. const n = Number(v) || 0;
  174. if (activeKey === 'online' || activeKey === 'tcpCount' || activeKey === 'udpCount') {
  175. return Math.round(n).toLocaleString();
  176. }
  177. return n.toFixed(2);
  178. };
  179. }
  180. function formatFullTimestamp(unixSec: number): string {
  181. const d = new Date(unixSec * 1000);
  182. const today = new Date();
  183. const sameDay =
  184. d.getFullYear() === today.getFullYear() &&
  185. d.getMonth() === today.getMonth() &&
  186. d.getDate() === today.getDate();
  187. const hh = String(d.getHours()).padStart(2, '0');
  188. const mm = String(d.getMinutes()).padStart(2, '0');
  189. const ss = String(d.getSeconds()).padStart(2, '0');
  190. const time = `${hh}:${mm}:${ss}`;
  191. if (sameDay) return time;
  192. const MM = String(d.getMonth() + 1).padStart(2, '0');
  193. const DD = String(d.getDate()).padStart(2, '0');
  194. return `${MM}-${DD} ${time}`;
  195. }
  196. interface HistoryChart {
  197. points: number[];
  198. points2: number[];
  199. points3: number[];
  200. labels: string[];
  201. timestamps: number[];
  202. }
  203. const EMPTY_CHART: HistoryChart = {
  204. points: [],
  205. points2: [],
  206. points3: [],
  207. labels: [],
  208. timestamps: [],
  209. };
  210. async function loadBucket(metric: (typeof METRICS)[number], bucket: number): Promise<HistoryChart> {
  211. try {
  212. const msg = await HttpUtil.get(`/panel/api/server/history/${metric.key}/${bucket}`);
  213. if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
  214. const points: number[] = [];
  215. const labels: string[] = [];
  216. const timestamps: number[] = [];
  217. for (const p of msg.obj) {
  218. const d = new Date(p.t * 1000);
  219. const MM = String(d.getMonth() + 1).padStart(2, '0');
  220. const DD = String(d.getDate()).padStart(2, '0');
  221. const hh = String(d.getHours()).padStart(2, '0');
  222. const mm = String(d.getMinutes()).padStart(2, '0');
  223. const ss = String(d.getSeconds()).padStart(2, '0');
  224. labels.push(
  225. bucket >= 2880
  226. ? `${MM}-${DD} ${hh}:${mm}`
  227. : bucket >= 60
  228. ? `${hh}:${mm}`
  229. : `${hh}:${mm}:${ss}`,
  230. );
  231. points.push(Number(p.v) || 0);
  232. timestamps.push(Number(p.t) || 0);
  233. }
  234. const fetchAligned = async (key?: string): Promise<number[]> => {
  235. if (!key) return [];
  236. const m = await HttpUtil.get(`/panel/api/server/history/${key}/${bucket}`);
  237. if (!m?.success || !Array.isArray(m.obj)) return [];
  238. const byTs = new Map<number, number>();
  239. for (const p of m.obj) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
  240. return timestamps.map((ts) => byTs.get(ts) ?? 0);
  241. };
  242. return {
  243. labels,
  244. points,
  245. timestamps,
  246. points2: await fetchAligned(metric.key2),
  247. points3: await fetchAligned(metric.key3),
  248. };
  249. } catch (e) {
  250. console.error('Failed to fetch history bucket', e);
  251. return EMPTY_CHART;
  252. }
  253. }
  254. export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) {
  255. const { t } = useTranslation();
  256. const { isMobile } = useMediaQuery();
  257. const [activeKey, setActiveKey] = useState('cpu');
  258. const [bucket, setBucket] = useState(2);
  259. const [{ points, points2, points3, labels, timestamps }, setChart] =
  260. useState<HistoryChart>(EMPTY_CHART);
  261. const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
  262. const trName = (n?: string) => (n && n.startsWith('pages.') ? t(n) : n);
  263. const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
  264. const yFormatter = useMemo(
  265. () => unitFormatter(activeMetric?.unit ?? '', activeKey),
  266. [activeMetric, activeKey],
  267. );
  268. const tsLookup = useMemo(() => {
  269. const m = new Map<string, number>();
  270. for (let i = 0; i < labels.length; i++) {
  271. m.set(labels[i], timestamps[i]);
  272. }
  273. return m;
  274. }, [labels, timestamps]);
  275. const tooltipLabelFormatter = useCallback(
  276. (label: string) => {
  277. const ts = tsLookup.get(label);
  278. return ts ? formatFullTimestamp(ts) : label;
  279. },
  280. [tsLookup],
  281. );
  282. const fetchBucket = useCallback(async () => {
  283. if (!activeMetric) return;
  284. const next = await loadBucket(activeMetric, bucket);
  285. setChart(next);
  286. }, [activeMetric, bucket]);
  287. const [wasOpen, setWasOpen] = useState(false);
  288. if (open !== wasOpen) {
  289. setWasOpen(open);
  290. if (open) setActiveKey('cpu');
  291. }
  292. useEffect(() => {
  293. if (!open || !activeMetric) return;
  294. let cancelled = false;
  295. void (async () => {
  296. const next = await loadBucket(activeMetric, bucket);
  297. if (!cancelled) setChart(next);
  298. })();
  299. return () => {
  300. cancelled = true;
  301. };
  302. }, [open, activeMetric, bucket]);
  303. useEffect(() => {
  304. if (!open) return undefined;
  305. const ms = bucket <= 30 ? 2000 : 10000;
  306. const id = window.setInterval(() => fetchBucket(), ms);
  307. return () => window.clearInterval(id);
  308. }, [open, bucket, fetchBucket]);
  309. return (
  310. <Modal
  311. open={open}
  312. footer={null}
  313. width={isMobile ? '95vw' : 900}
  314. onCancel={onClose}
  315. title={
  316. <div className="metric-modal-title">
  317. <span>{t('pages.index.systemHistoryTitle')}</span>
  318. <Select
  319. value={bucket}
  320. size="small"
  321. className="bucket-select"
  322. onChange={setBucket}
  323. options={[
  324. { value: 2, label: '2m' },
  325. { value: 60, label: '1h' },
  326. { value: 180, label: '3h' },
  327. { value: 360, label: '6h' },
  328. { value: 720, label: '12h' },
  329. { value: 1440, label: '24h' },
  330. { value: 2880, label: '2d' },
  331. { value: 10080, label: '7d' },
  332. ]}
  333. />
  334. </div>
  335. }
  336. >
  337. <Tabs
  338. activeKey={activeKey}
  339. onChange={setActiveKey}
  340. size="small"
  341. className="history-tabs"
  342. items={METRICS.map((m) => {
  343. const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
  344. return {
  345. key: m.key,
  346. label: isMobile ? (
  347. <span title={tabLabel} aria-label={tabLabel}>
  348. {m.icon}
  349. </span>
  350. ) : (
  351. tabLabel
  352. ),
  353. };
  354. })}
  355. />
  356. <div className="cpu-chart-wrap">
  357. {activeMetric?.title && <div className="history-chart-title">{t(activeMetric.title)}</div>}
  358. <Sparkline
  359. data={points}
  360. data2={activeMetric?.key2 ? points2 : undefined}
  361. data3={activeMetric?.key3 ? points3 : undefined}
  362. stroke2={activeMetric?.stroke2}
  363. stroke3={activeMetric?.stroke3}
  364. name1={trName(activeMetric?.name1)}
  365. name2={trName(activeMetric?.name2)}
  366. name3={trName(activeMetric?.name3)}
  367. labels={labels}
  368. height={260}
  369. stroke={strokeColor}
  370. strokeWidth={2.2}
  371. showGrid
  372. showAxes
  373. tickCountX={5}
  374. maxPoints={points.length || 1}
  375. fillOpacity={0.18}
  376. markerRadius={3.2}
  377. showTooltip
  378. valueMin={0}
  379. valueMax={activeMetric?.valueMax ?? null}
  380. yFormatter={yFormatter}
  381. tooltipLabelFormatter={tooltipLabelFormatter}
  382. extrema={{ show: !activeMetric?.key2, formatter: yFormatter }}
  383. />
  384. </div>
  385. </Modal>
  386. );
  387. }