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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m57s
This commit is contained in:
+138
-24
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user