Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
339 lines
12 KiB
React
339 lines
12 KiB
React
import { useState, useEffect } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import api from './lib/api.js';
|
|
import { formatDateTime } from './lib/datetime.js';
|
|
import {
|
|
IconWorld,
|
|
IconNetwork,
|
|
IconServer,
|
|
IconClock,
|
|
IconMapPin,
|
|
IconCloud,
|
|
IconShield,
|
|
IconAlertTriangle,
|
|
IconRefresh,
|
|
IconFilter,
|
|
IconDownload,
|
|
IconCreditCard,
|
|
IconSearch
|
|
} from '@tabler/icons-react';
|
|
import PageHeader from './components/PageHeader.jsx';
|
|
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
|
import TopNStats from './components/TopNStats.jsx';
|
|
import TrendIndicator from './components/TrendIndicator.jsx';
|
|
import LastSaved from './components/LastSaved.jsx';
|
|
import Tooltip from './components/Tooltip.jsx';
|
|
|
|
function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) {
|
|
return (
|
|
<div className="card h-100 position-relative card-hover">
|
|
<div className="card-body d-flex align-items-center">
|
|
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
|
<Icon size={32} />
|
|
</span>
|
|
<div className="flex-grow-1">
|
|
<div className="d-flex align-items-baseline gap-2 mb-1">
|
|
<div className="h3 mb-0 fw-bold">{value}</div>
|
|
{trend && previousValue !== undefined && (
|
|
<TrendIndicator
|
|
value={typeof value === 'number' ? value : 0}
|
|
previousValue={previousValue}
|
|
format="number"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="text-muted small">{title}</div>
|
|
{subtitle && (
|
|
<div className="text-muted small opacity-75">{subtitle}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{to && (
|
|
<Link to={to} className="stretched-link" aria-label={`Перейти к ${title}`}></Link>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricCard({ title, value, icon: Icon, color, description }) {
|
|
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`}>
|
|
<Icon size={32} />
|
|
</span>
|
|
<div className="flex-grow-1">
|
|
<div className="h3 mb-0 fw-bold">
|
|
{value} <span className="fs-5 fw-normal">{title}</span>
|
|
</div>
|
|
{description && (
|
|
<div className="text-muted lh-1">{description}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Dashboard() {
|
|
// Глобальный поиск удалён по требованию UX
|
|
const [stats, setStats] = useState({
|
|
domainsCount: null,
|
|
ipRangesCount: null,
|
|
asnsCount: null,
|
|
serversCount: null,
|
|
lastModified: null,
|
|
countriesCount: null,
|
|
providersCount: null,
|
|
onlineServers: null,
|
|
totalServers: null
|
|
});
|
|
const [previousStats, setPreviousStats] = useState(null);
|
|
const [raw, setRaw] = useState({ domains: [], ipRanges: [], asns: [], servers: [] });
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [lastFetchTime, setLastFetchTime] = useState(null);
|
|
|
|
useEffect(() => {
|
|
async function fetchStats() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
// Загружаем данные с обработкой ошибок для каждого endpoint
|
|
const results = await Promise.allSettled([
|
|
api.get('/domains-new', { params: { countOnly: true } }),
|
|
api.get('/ip-ranges', { params: { countOnly: true } }),
|
|
api.get('/asns', { params: { countOnly: true } }),
|
|
api.get('/servers'),
|
|
api.get('/servers/availability', { params: { ttlSeconds: 60 } }),
|
|
api.get('/s3/last-modified')
|
|
]);
|
|
|
|
// Обрабатываем результаты
|
|
const domainsRes = results[0];
|
|
const ipRangesRes = results[1];
|
|
const asnsRes = results[2];
|
|
const serversRes = results[3];
|
|
const availabilityRes = results[4];
|
|
const s3Res = results[5];
|
|
|
|
// Получаем данные серверов для подсчета дополнительной статистики
|
|
const domains = [];
|
|
const ipRanges = [];
|
|
const asns = [];
|
|
const serversRaw = serversRes.status === 'fulfilled' ? serversRes.value.data : [];
|
|
const servers = Array.isArray(serversRaw) ? serversRaw : [];
|
|
setRaw({ domains, ipRanges, asns, servers });
|
|
const countries = new Set(servers.map(server => server.country).filter(Boolean));
|
|
const providers = new Set(servers.map(server => server.provider).filter(Boolean));
|
|
const onlineServers = availabilityRes.status === 'fulfilled' ? (availabilityRes.value.data?.online || 0) : (servers.filter(server => server.status === 'Онлайн').length);
|
|
|
|
// Получаем дату последнего обновления
|
|
// Новый формат: объект с ключами { domainsNew, asns, servers, filters, ipRanges }
|
|
const lmRaw = s3Res.status === 'fulfilled' ? s3Res.value.data?.domainsNew?.lastModified : null;
|
|
const lastModified = lmRaw ? formatDateTime(lmRaw) : formatDateTime(new Date());
|
|
|
|
const domainsCount = domainsRes.status === 'fulfilled' && typeof domainsRes.value.data?.total === 'number'
|
|
? domainsRes.value.data.total
|
|
: 0;
|
|
const ipRangesCount = ipRangesRes.status === 'fulfilled' && typeof ipRangesRes.value.data?.total === 'number'
|
|
? ipRangesRes.value.data.total
|
|
: 0;
|
|
const asnsCount = asnsRes.status === 'fulfilled' && typeof asnsRes.value.data?.total === 'number'
|
|
? asnsRes.value.data.total
|
|
: 0;
|
|
|
|
const newStats = {
|
|
domainsCount,
|
|
ipRangesCount,
|
|
asnsCount,
|
|
serversCount: servers.length,
|
|
lastModified,
|
|
countriesCount: countries.size,
|
|
providersCount: providers.size,
|
|
onlineServers,
|
|
totalServers: servers.length
|
|
};
|
|
|
|
// Сохраняем предыдущие значения перед обновлением
|
|
setStats(prevStats => {
|
|
if (prevStats.domainsCount !== null) {
|
|
setPreviousStats(prevStats);
|
|
}
|
|
return newStats;
|
|
});
|
|
|
|
setLastFetchTime(new Date().toISOString());
|
|
|
|
// Проверяем, есть ли ошибки
|
|
const errors = results.filter(result => result.status === 'rejected');
|
|
if (errors.length > 0) {
|
|
console.warn('Некоторые API недоступны:', errors.map(e => e.reason?.message));
|
|
}
|
|
} catch (e) {
|
|
console.error('Критическая ошибка загрузки статистики:', e);
|
|
setError('Не удалось загрузить статистику');
|
|
setStats({
|
|
domainsCount: null,
|
|
ipRangesCount: null,
|
|
asnsCount: null,
|
|
serversCount: null,
|
|
lastModified: null,
|
|
countriesCount: null,
|
|
providersCount: null,
|
|
onlineServers: null,
|
|
totalServers: null
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
fetchStats();
|
|
}, []);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="alert alert-danger" role="alert">
|
|
<IconAlertTriangle className="me-2" />
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Глобальный поиск удалён */}
|
|
{/* Заголовок страницы */}
|
|
<PageHeader
|
|
title="Панель"
|
|
pretitle={(
|
|
<Breadcrumbs items={[
|
|
{ label: 'Главная' },
|
|
]} />
|
|
)}
|
|
actions={(
|
|
<div className="d-flex align-items-center gap-3">
|
|
{lastFetchTime && (
|
|
<LastSaved timestamp={lastFetchTime} variant="compact" />
|
|
)}
|
|
<Tooltip content="Обновить данные" shortcut="⌘R">
|
|
<button className="btn btn-outline-primary" onClick={() => window.location.reload()}>
|
|
<IconRefresh className="me-2" /> Обновить
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
{/* Основные метрики */}
|
|
<div className="row g-3 mb-4">
|
|
<div className="col-md-3">
|
|
<div className="animate-in">
|
|
<StatCard
|
|
icon={IconWorld}
|
|
color="blue"
|
|
value={loading ? '...' : (stats.domainsCount ?? '—')}
|
|
title="Доменов"
|
|
subtitle="Всего доменов в системе"
|
|
to="/domains"
|
|
trend={!loading && previousStats}
|
|
previousValue={previousStats?.domainsCount}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="animate-in" style={{animationDelay: '0.1s'}}>
|
|
<StatCard
|
|
icon={IconNetwork}
|
|
color="green"
|
|
value={loading ? '...' : (stats.ipRangesCount ?? '—')}
|
|
title="IP-диапазонов"
|
|
subtitle="Всего IP диапазонов"
|
|
to="/ip-ranges"
|
|
trend={!loading && previousStats}
|
|
previousValue={previousStats?.ipRangesCount}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="animate-in" style={{animationDelay: '0.2s'}}>
|
|
<StatCard
|
|
icon={IconNetwork}
|
|
color="purple"
|
|
value={loading ? '...' : (stats.asnsCount ?? '—')}
|
|
title="AS"
|
|
subtitle="Всего Autonomous Systems"
|
|
to="/asns"
|
|
trend={!loading && previousStats}
|
|
previousValue={previousStats?.asnsCount}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="animate-in" style={{animationDelay: '0.3s'}}>
|
|
<StatCard
|
|
icon={IconServer}
|
|
color="orange"
|
|
value={loading ? '...' : (stats.serversCount ?? '—')}
|
|
title="Серверов"
|
|
subtitle="Всего серверов"
|
|
to="/servers"
|
|
trend={!loading && previousStats}
|
|
previousValue={previousStats?.serversCount}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Дополнительные метрики */}
|
|
<div className="row g-3 mb-4 justify-content-center">
|
|
<div className="col-sm-6 col-md-3">
|
|
<MetricCard
|
|
title="Стран"
|
|
value={loading ? '...' : stats.countriesCount ?? '—'}
|
|
icon={IconMapPin}
|
|
color="blue"
|
|
description="Географическое покрытие"
|
|
/>
|
|
</div>
|
|
<div className="col-sm-6 col-md-3">
|
|
<MetricCard
|
|
title="Провайдеров"
|
|
value={loading ? '...' : stats.providersCount ?? '—'}
|
|
icon={IconCloud}
|
|
color="green"
|
|
description="Облачные провайдеры"
|
|
/>
|
|
</div>
|
|
<div className="col-sm-6 col-md-3">
|
|
<MetricCard
|
|
title="Онлайн серверов"
|
|
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
|
|
icon={IconServer}
|
|
color="success"
|
|
description="Активные серверы"
|
|
/>
|
|
</div>
|
|
<div className="col-sm-6 col-md-3">
|
|
<MetricCard
|
|
title="Последнее обновление"
|
|
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')}
|
|
icon={IconClock}
|
|
color="orange"
|
|
description="S3 синхронизация"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top-N статистика */}
|
|
<div className="mb-4">
|
|
<h3 className="mb-3">Топ статистика</h3>
|
|
<TopNStats data={{ servers: raw.servers }} loading={loading} />
|
|
</div>
|
|
|
|
{/* Убрали быстрые действия, карточки выше кликабельны */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Dashboard; |