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 { Link } from 'react-router-dom';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import { formatDateTime } from './lib/datetime.js';
|
import { formatDateTime } from './lib/datetime.js';
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
IconDownload,
|
IconDownload,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronUp,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { getIconById } from './lib/brandIcons.js';
|
import { getIconById } from './lib/brandIcons.js';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
@@ -23,6 +25,7 @@ import TopNStats from './components/TopNStats.jsx';
|
|||||||
import TrendIndicator from './components/TrendIndicator.jsx';
|
import TrendIndicator from './components/TrendIndicator.jsx';
|
||||||
import LastSaved from './components/LastSaved.jsx';
|
import LastSaved from './components/LastSaved.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
import Sparkline from './components/Sparkline.jsx';
|
||||||
|
|
||||||
function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) {
|
function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) {
|
||||||
return (
|
return (
|
||||||
@@ -75,24 +78,94 @@ function MetricCard({ title, value, icon: Icon, color, description }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Карточка сервиса с иконкой и пингом (config из API: id, name, host, icon, color) */
|
const PING_HISTORY_MAX = 30;
|
||||||
function PingServiceCard({ config, ms, loading }) {
|
|
||||||
|
/** Обёртка для 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 { name, host, color } = config;
|
||||||
const { Icon } = getIconById(config.icon);
|
const { Icon } = getIconById(config.icon);
|
||||||
const value = loading ? '...' : (ms != null ? `${ms} мс` : '—');
|
const value = loading ? '...' : (ms != null ? `${ms} мс` : '—');
|
||||||
|
const currentNum = typeof ms === 'number' ? ms : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card h-100 position-relative">
|
<div
|
||||||
<div className="card-body d-flex align-items-center">
|
className={`card h-100 position-relative ${isExpanded ? '' : 'cursor-pointer'}`}
|
||||||
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0 rounded`}>
|
style={isExpanded ? { minHeight: '200px' } : undefined}
|
||||||
<Icon size={28} stroke={1.5} />
|
role="button"
|
||||||
</span>
|
tabIndex={0}
|
||||||
<div className="flex-grow-1 min-w-0">
|
onClick={onToggle}
|
||||||
<div className="h4 mb-0 fw-bold">{value}</div>
|
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggle()}
|
||||||
<div className="text-muted small text-truncate" title={`${name} (${host})`}>
|
aria-expanded={isExpanded}
|
||||||
{name} <span className="opacity-75">({host})</span>
|
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>
|
</div>
|
||||||
|
<IconChevronDown size={18} className="text-muted ms-1 flex-shrink-0" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,6 +191,9 @@ function Dashboard() {
|
|||||||
const [pingServices, setPingServices] = useState(null);
|
const [pingServices, setPingServices] = useState(null);
|
||||||
const [pingServicesConfig, setPingServicesConfig] = useState([]);
|
const [pingServicesConfig, setPingServicesConfig] = useState([]);
|
||||||
const [pingLoading, setPingLoading] = useState(true);
|
const [pingLoading, setPingLoading] = useState(true);
|
||||||
|
const [previousPingServices, setPreviousPingServices] = useState(null);
|
||||||
|
const [pingHistory, setPingHistory] = useState(() => ({}));
|
||||||
|
const [expandedPingId, setExpandedPingId] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
@@ -228,20 +304,37 @@ function Dashboard() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
function fetchPingServices() {
|
||||||
let cancelled = false;
|
|
||||||
setPingLoading(true);
|
setPingLoading(true);
|
||||||
api.get('/ping-services')
|
api.get('/ping-services')
|
||||||
.then(({ data }) => {
|
.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(() => {
|
.catch(() => setPingServices(null))
|
||||||
if (!cancelled) setPingServices(null);
|
.finally(() => setPingLoading(false));
|
||||||
})
|
}
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setPingLoading(false);
|
useEffect(() => {
|
||||||
});
|
fetchPingServices();
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
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">
|
<div className="row g-3 mb-4">
|
||||||
{pingServicesConfig.map((config) => (
|
{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
|
<PingServiceCard
|
||||||
config={config}
|
config={config}
|
||||||
ms={pingServices?.[config.id]?.ms ?? null}
|
ms={pingServices?.[config.id]?.ms ?? null}
|
||||||
|
previousMs={previousPingServices?.[config.id]?.ms ?? null}
|
||||||
|
history={pingHistory[config.id] || []}
|
||||||
loading={pingLoading}
|
loading={pingLoading}
|
||||||
|
isExpanded={expandedPingId === config.id}
|
||||||
|
onToggle={() => setExpandedPingId((id) => (id === config.id ? null : config.id))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user