diff --git a/frontend/src/Dashboard.jsx b/frontend/src/Dashboard.jsx index 0b5c534..06d47bf 100644 --- a/frontend/src/Dashboard.jsx +++ b/frontend/src/Dashboard.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import api from './lib/api.js'; import { formatDateTime } from './lib/datetime.js'; @@ -16,6 +16,8 @@ import { IconDownload, IconCreditCard, IconSearch, + IconChevronDown, + IconChevronUp, } from '@tabler/icons-react'; import { getIconById } from './lib/brandIcons.js'; import PageHeader from './components/PageHeader.jsx'; @@ -23,6 +25,7 @@ import TopNStats from './components/TopNStats.jsx'; import TrendIndicator from './components/TrendIndicator.jsx'; import LastSaved from './components/LastSaved.jsx'; import Tooltip from './components/Tooltip.jsx'; +import Sparkline from './components/Sparkline.jsx'; function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) { return ( @@ -75,24 +78,94 @@ function MetricCard({ title, value, icon: Icon, color, description }) { ); } -/** Карточка сервиса с иконкой и пингом (config из API: id, name, host, icon, color) */ -function PingServiceCard({ config, ms, loading }) { +const PING_HISTORY_MAX = 30; + +/** Обёртка для Sparkline по ширине контейнера */ +function PingSparklineWrap({ history, color }) { + const wrapRef = useRef(null); + const [width, setWidth] = useState(280); + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setWidth(el.offsetWidth || 280)); + ro.observe(el); + setWidth(el.offsetWidth || 280); + return () => ro.disconnect(); + }, []); + return ( +
+ +
+ ); +} + +/** Разворачиваемая карточка сервиса: свёрнутый вид — иконка и пинг; развёрнутый — тренд и мини-график (как Revenue в Tabler) */ +function PingServiceCard({ config, ms, previousMs, history = [], loading, isExpanded, onToggle }) { const { name, host, color } = config; const { Icon } = getIconById(config.icon); const value = loading ? '...' : (ms != null ? `${ms} мс` : '—'); + const currentNum = typeof ms === 'number' ? ms : null; + return ( -
-
- - - -
-
{value}
-
- {name} ({host}) +
(e.key === 'Enter' || e.key === ' ') && onToggle()} + aria-expanded={isExpanded} + aria-label={isExpanded ? `Свернуть ${name}` : `Развернуть ${name}, пинг ${value}`} + > + {!isExpanded ? ( +
+ + + +
+
{value}
+
+ {name} ({host}) +
+
-
+ ) : ( +
+
+ {name} + + Последние замеры + + +
+
+
+ {loading ? '...' : currentNum != null ? `${currentNum} мс` : '—'} +
+ {!loading && currentNum != null && previousMs != null && ( + + )} +
+ {history.length > 0 ? ( + + ) : ( +
Нет истории замеров. Данные накапливаются при обновлении.
+ )} +
+ )}
); } @@ -118,6 +191,9 @@ function Dashboard() { const [pingServices, setPingServices] = useState(null); const [pingServicesConfig, setPingServicesConfig] = useState([]); const [pingLoading, setPingLoading] = useState(true); + const [previousPingServices, setPreviousPingServices] = useState(null); + const [pingHistory, setPingHistory] = useState(() => ({})); + const [expandedPingId, setExpandedPingId] = useState(null); useEffect(() => { async function fetchStats() { @@ -228,20 +304,37 @@ function Dashboard() { return () => { cancelled = true; }; }, []); - useEffect(() => { - let cancelled = false; + function fetchPingServices() { setPingLoading(true); api.get('/ping-services') .then(({ data }) => { - if (!cancelled) setPingServices(data || null); + const next = data && typeof data === 'object' ? data : null; + setPingServices((current) => { + if (current && typeof current === 'object' && Object.keys(current).length > 0) { + setPreviousPingServices(current); + } + return next; + }); + if (next) { + setPingHistory((h) => { + const out = { ...h }; + Object.keys(next).forEach((id) => { + const ms = next[id]?.ms; + if (typeof ms !== 'number') return; + const list = Array.isArray(out[id]) ? out[id] : []; + const nextList = [...list, ms].slice(-PING_HISTORY_MAX); + out[id] = nextList; + }); + return out; + }); + } }) - .catch(() => { - if (!cancelled) setPingServices(null); - }) - .finally(() => { - if (!cancelled) setPingLoading(false); - }); - return () => { cancelled = true; }; + .catch(() => setPingServices(null)) + .finally(() => setPingLoading(false)); + } + + useEffect(() => { + fetchPingServices(); }, []); if (error) { @@ -275,13 +368,34 @@ function Dashboard() { /> {/* Пинг до сервисов (список из настроек / Пинг сервисов) */} +
+

Пинг сервисов

+ + + +
{pingServicesConfig.map((config) => ( -
+
setExpandedPingId((id) => (id === config.id ? null : config.id))} />
))}