feat(Dashboard): enhance PingServiceCard with expandable view and sparkline for historical ping data
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m57s

This commit is contained in:
2026-02-22 21:24:48 +07:00
parent 7d3804202f
commit fbd740aef7
+138 -24
View File
@@ -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 (
<div ref={wrapRef} className="mt-2" style={{ height: 56, width: '100%', minWidth: 0 }}>
<Sparkline
data={history}
width={width}
height={56}
strokeColor={color ? `var(--tblr-${color})` : 'var(--tblr-primary)'}
fillColor={color ? `var(--tblr-${color})` : 'var(--tblr-primary)'}
strokeWidth={2}
/>
</div>
);
}
/** Разворачиваемая карточка сервиса: свёрнутый вид — иконка и пинг; развёрнутый — тренд и мини-график (как 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 (
<div className="card h-100 position-relative">
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0 rounded`}>
<Icon size={28} stroke={1.5} />
</span>
<div className="flex-grow-1 min-w-0">
<div className="h4 mb-0 fw-bold">{value}</div>
<div className="text-muted small text-truncate" title={`${name} (${host})`}>
{name} <span className="opacity-75">({host})</span>
<div
className={`card h-100 position-relative ${isExpanded ? '' : 'cursor-pointer'}`}
style={isExpanded ? { minHeight: '200px' } : undefined}
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggle()}
aria-expanded={isExpanded}
aria-label={isExpanded ? `Свернуть ${name}` : `Развернуть ${name}, пинг ${value}`}
>
{!isExpanded ? (
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0 rounded`}>
<Icon size={28} stroke={1.5} />
</span>
<div className="flex-grow-1 min-w-0">
<div className="h4 mb-0 fw-bold">{value}</div>
<div className="text-muted small text-truncate" title={`${name} (${host})`}>
{name} <span className="opacity-75">({host})</span>
</div>
</div>
<IconChevronDown size={18} className="text-muted ms-1 flex-shrink-0" />
</div>
</div>
) : (
<div className="card-body">
<div className="d-flex align-items-center justify-content-between mb-2">
<span className="text-muted text-uppercase small fw-semibold">{name}</span>
<span className="text-muted small d-flex align-items-center">
Последние замеры
<IconChevronUp size={14} className="ms-1" title="Свернуть" />
</span>
</div>
<div className="d-flex align-items-baseline flex-wrap gap-2 mb-3">
<div className="h3 mb-0 fw-bold">
{loading ? '...' : currentNum != null ? `${currentNum} мс` : '—'}
</div>
{!loading && currentNum != null && previousMs != null && (
<TrendIndicator
value={currentNum}
previousValue={previousMs}
format="percent"
inverse
/>
)}
</div>
{history.length > 0 ? (
<PingSparklineWrap history={history} color={color} />
) : (
<div className="text-muted small mt-2">Нет истории замеров. Данные накапливаются при обновлении.</div>
)}
</div>
)}
</div>
);
}
@@ -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() {
/>
{/* Пинг до сервисов (список из настроек / Пинг сервисов) */}
<div className="d-flex align-items-center justify-content-between mb-2">
<h3 className="mb-0">Пинг сервисов</h3>
<Tooltip content="Обновить замеры пинга (история и тренд накапливаются)">
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => fetchPingServices()}
disabled={pingLoading}
>
<IconRefresh className={pingLoading ? 'spin me-1' : 'me-1'} size={16} />
Обновить пинг
</button>
</Tooltip>
</div>
<div className="row g-3 mb-4">
{pingServicesConfig.map((config) => (
<div key={config.id} className="col-6 col-md-3">
<div
key={config.id}
className={expandedPingId === config.id ? 'col-12' : 'col-6 col-md-3'}
>
<PingServiceCard
config={config}
ms={pingServices?.[config.id]?.ms ?? null}
previousMs={previousPingServices?.[config.id]?.ms ?? null}
history={pingHistory[config.id] || []}
loading={pingLoading}
isExpanded={expandedPingId === config.id}
onToggle={() => setExpandedPingId((id) => (id === config.id ? null : config.id))}
/>
</div>
))}