SystemHistoryModal.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Modal, Select, Tabs } from 'antd';
  4. import { HttpUtil, SizeFormatter } from '@/utils';
  5. import Sparkline from '@/components/Sparkline';
  6. import { useMediaQuery } from '@/hooks/useMediaQuery';
  7. import type { Status } from '@/models/status';
  8. import './SystemHistoryModal.css';
  9. interface SystemHistoryModalProps {
  10. open: boolean;
  11. status: Status;
  12. onClose: () => void;
  13. }
  14. interface MetricDef {
  15. key: string;
  16. tab: string;
  17. valueMax: number | null;
  18. unit: string;
  19. stroke: string;
  20. }
  21. const METRICS: MetricDef[] = [
  22. { key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' },
  23. { key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' },
  24. { key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' },
  25. { key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' },
  26. { key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' },
  27. { key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' },
  28. { key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' },
  29. { key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' },
  30. ];
  31. function unitFormatter(unit: string, activeKey: string): (v: number) => string {
  32. if (unit === 'B/s') {
  33. return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0)).replace(/\.\d+/, '')}/s`;
  34. }
  35. if (unit === '%') {
  36. return (v) => `${Number(v).toFixed(1)}%`;
  37. }
  38. return (v) => {
  39. const n = Number(v) || 0;
  40. if (activeKey === 'online') return String(Math.round(n));
  41. return n.toFixed(2);
  42. };
  43. }
  44. function formatFullTimestamp(unixSec: number): string {
  45. const d = new Date(unixSec * 1000);
  46. const today = new Date();
  47. const sameDay = d.getFullYear() === today.getFullYear()
  48. && d.getMonth() === today.getMonth()
  49. && d.getDate() === today.getDate();
  50. const hh = String(d.getHours()).padStart(2, '0');
  51. const mm = String(d.getMinutes()).padStart(2, '0');
  52. const ss = String(d.getSeconds()).padStart(2, '0');
  53. const time = `${hh}:${mm}:${ss}`;
  54. if (sameDay) return time;
  55. const MM = String(d.getMonth() + 1).padStart(2, '0');
  56. const DD = String(d.getDate()).padStart(2, '0');
  57. return `${MM}-${DD} ${time}`;
  58. }
  59. export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) {
  60. const { t } = useTranslation();
  61. const { isMobile } = useMediaQuery();
  62. const [activeKey, setActiveKey] = useState('cpu');
  63. const [bucket, setBucket] = useState(2);
  64. const [points, setPoints] = useState<number[]>([]);
  65. const [labels, setLabels] = useState<string[]>([]);
  66. const [timestamps, setTimestamps] = useState<number[]>([]);
  67. const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
  68. const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
  69. const yFormatter = useMemo(
  70. () => unitFormatter(activeMetric?.unit ?? '', activeKey),
  71. [activeMetric, activeKey],
  72. );
  73. const tsLookup = useMemo(() => {
  74. const m = new Map<string, number>();
  75. for (let i = 0; i < labels.length; i++) {
  76. m.set(labels[i], timestamps[i]);
  77. }
  78. return m;
  79. }, [labels, timestamps]);
  80. const tooltipLabelFormatter = useCallback(
  81. (label: string) => {
  82. const ts = tsLookup.get(label);
  83. return ts ? formatFullTimestamp(ts) : label;
  84. },
  85. [tsLookup],
  86. );
  87. const fetchBucket = useCallback(async () => {
  88. if (!activeMetric) return;
  89. try {
  90. const url = `/panel/api/server/history/${activeMetric.key}/${bucket}`;
  91. const msg = await HttpUtil.get(url);
  92. if (msg?.success && Array.isArray(msg.obj)) {
  93. const vals: number[] = [];
  94. const labs: string[] = [];
  95. const tss: number[] = [];
  96. for (const p of msg.obj) {
  97. const d = new Date(p.t * 1000);
  98. const hh = String(d.getHours()).padStart(2, '0');
  99. const mm = String(d.getMinutes()).padStart(2, '0');
  100. const ss = String(d.getSeconds()).padStart(2, '0');
  101. labs.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
  102. vals.push(Number(p.v) || 0);
  103. tss.push(Number(p.t) || 0);
  104. }
  105. setLabels(labs);
  106. setPoints(vals);
  107. setTimestamps(tss);
  108. } else {
  109. setLabels([]);
  110. setPoints([]);
  111. setTimestamps([]);
  112. }
  113. } catch (e) {
  114. console.error('Failed to fetch history bucket', e);
  115. setLabels([]);
  116. setPoints([]);
  117. setTimestamps([]);
  118. }
  119. }, [activeMetric, bucket]);
  120. useEffect(() => {
  121. if (open) setActiveKey('cpu');
  122. }, [open]);
  123. useEffect(() => {
  124. if (open) fetchBucket();
  125. }, [open, activeKey, bucket, fetchBucket]);
  126. return (
  127. <Modal
  128. open={open}
  129. footer={null}
  130. width={isMobile ? '95vw' : 900}
  131. onCancel={onClose}
  132. title={
  133. <div className="metric-modal-title">
  134. <span>{t('pages.index.systemHistoryTitle')}</span>
  135. <Select
  136. value={bucket}
  137. size="small"
  138. className="bucket-select"
  139. onChange={setBucket}
  140. options={[
  141. { value: 2, label: '2m' },
  142. { value: 30, label: '30m' },
  143. { value: 60, label: '1h' },
  144. { value: 120, label: '2h' },
  145. { value: 180, label: '3h' },
  146. { value: 300, label: '5h' },
  147. ]}
  148. />
  149. </div>
  150. }
  151. >
  152. <Tabs
  153. activeKey={activeKey}
  154. onChange={setActiveKey}
  155. size="small"
  156. className="history-tabs"
  157. items={METRICS.map((m) => ({ key: m.key, label: m.tab }))}
  158. />
  159. <div className="cpu-chart-wrap">
  160. <Sparkline
  161. data={points}
  162. labels={labels}
  163. height={260}
  164. stroke={strokeColor}
  165. strokeWidth={2.2}
  166. showGrid
  167. showAxes
  168. tickCountX={5}
  169. maxPoints={points.length || 1}
  170. fillOpacity={0.18}
  171. markerRadius={3.2}
  172. showTooltip
  173. valueMin={0}
  174. valueMax={activeMetric?.valueMax ?? null}
  175. yFormatter={yFormatter}
  176. tooltipLabelFormatter={tooltipLabelFormatter}
  177. extrema={{ show: true, formatter: yFormatter }}
  178. />
  179. </div>
  180. </Modal>
  181. );
  182. }