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))}
/>
))}