feat: Добавить улучшенный rate limiting для операций записи и BGP обновлений, а также валидацию входных данных для ASNs, доменов и IP диапазонов. Реализовать отображение статистики использования сообществ и новый API для валидации конфигурации MikroTik. Обновить интерфейс менеджера сообществ с вкладками для списка и статистики.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m30s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m30s
This commit is contained in:
@@ -13,13 +13,16 @@ import {
|
||||
IconDownload,
|
||||
IconDeviceFloppy,
|
||||
IconHash,
|
||||
IconFilter
|
||||
IconFilter,
|
||||
IconChartBar,
|
||||
IconList
|
||||
} from '@tabler/icons-react';
|
||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import EmptyState from './components/EmptyState.jsx';
|
||||
import { TableEmpty } from './components/TableSkeleton.jsx';
|
||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
||||
import CommunityStats from './components/CommunityStats.jsx';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
@@ -33,10 +36,13 @@ function CommunitiesManager() {
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
// Состояние для переключения между списком и статистикой
|
||||
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
|
||||
|
||||
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
||||
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
||||
const [editingValue, setEditingValue] = useState(null);
|
||||
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
||||
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
||||
const editRef = useRef(null);
|
||||
|
||||
useEffect(() => { fetchItems(); }, []);
|
||||
@@ -251,6 +257,30 @@ function CommunitiesManager() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Вкладки: Список / Статистика */}
|
||||
<div className="card mb-3">
|
||||
<div className="card-body p-2">
|
||||
<div className="btn-group w-100" role="group">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeTab === 'list' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setActiveTab('list')}
|
||||
>
|
||||
<IconList className="me-2" size={18} />
|
||||
Список communities
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeTab === 'stats' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
>
|
||||
<IconChartBar className="me-2" size={18} />
|
||||
Статистика использования
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<IconX className="me-2" />{error}
|
||||
@@ -264,6 +294,8 @@ function CommunitiesManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение списка communities */}
|
||||
{activeTab === 'list' && (
|
||||
<div className="row g-3">
|
||||
<div className="col-lg-3">
|
||||
<div className="card card-md">
|
||||
@@ -408,6 +440,12 @@ function CommunitiesManager() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение статистики communities */}
|
||||
{activeTab === 'stats' && (
|
||||
<CommunityStats communities={items} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
||||
import TopNStats from './components/TopNStats.jsx';
|
||||
|
||||
function StatCard({ icon: Icon, color, value, title, subtitle, to }) {
|
||||
return (
|
||||
@@ -269,6 +270,12 @@ function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-N статистика */}
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-3">Топ статистика</h3>
|
||||
<TopNStats data={{ servers: raw.servers }} loading={loading} />
|
||||
</div>
|
||||
|
||||
{/* Убрали быстрые действия, карточки выше кликабельны */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../lib/api.js';
|
||||
import { IconChartBar, IconRefresh, IconTags } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Компонент для отображения статистики использования communities
|
||||
*/
|
||||
function CommunityStats({ communities = [] }) {
|
||||
const [stats, setStats] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchStats = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get('/communities/stats');
|
||||
setStats(res.data?.stats || []);
|
||||
} catch (e) {
|
||||
console.error('Error loading community stats:', e);
|
||||
setError('Не удалось загрузить статистику');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
// Объединяем статистику с метаданными communities
|
||||
const enrichedStats = stats.map(stat => {
|
||||
const meta = communities.find(c => c.value === stat.community);
|
||||
return {
|
||||
...stat,
|
||||
name: meta?.name || '',
|
||||
description: meta?.description || '',
|
||||
tags: meta?.tags || [],
|
||||
color: meta?.color || '',
|
||||
category: meta?.category || '',
|
||||
};
|
||||
});
|
||||
|
||||
// Группируем по категориям
|
||||
const statsByCategory = enrichedStats.reduce((acc, stat) => {
|
||||
const category = stat.category || 'Без категории';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(stat);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body text-center py-4">
|
||||
<div className="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<span className="text-muted">Загрузка статистики...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="alert alert-warning mb-0">
|
||||
{error}
|
||||
<button className="btn btn-sm btn-outline-primary ms-2" onClick={fetchStats}>
|
||||
<IconRefresh size={14} className="me-1" /> Повторить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalUsage = stats.reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<h3 className="card-title">
|
||||
<IconChartBar className="me-2" />
|
||||
Статистика использования Communities
|
||||
</h3>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={fetchStats} disabled={loading}>
|
||||
<IconRefresh size={14} className="me-1" /> Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{stats.length === 0 ? (
|
||||
<div className="text-center text-muted py-3">
|
||||
Нет данных для отображения статистики
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Общая статистика */}
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-4">
|
||||
<div className="card card-sm bg-blue-lt">
|
||||
<div className="card-body">
|
||||
<div className="h1 mb-0">{stats.length}</div>
|
||||
<div className="text-muted">Уникальных communities</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card card-sm bg-green-lt">
|
||||
<div className="card-body">
|
||||
<div className="h1 mb-0">{totalUsage}</div>
|
||||
<div className="text-muted">Всего использований</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card card-sm bg-purple-lt">
|
||||
<div className="card-body">
|
||||
<div className="h1 mb-0">{Object.keys(statsByCategory).length}</div>
|
||||
<div className="text-muted">Категорий</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-10 communities */}
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-3">
|
||||
<IconChartBar className="me-2" size={20} />
|
||||
Top-10 самых используемых
|
||||
</h4>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '10%' }}>#</th>
|
||||
<th style={{ width: '25%' }}>Community</th>
|
||||
<th style={{ width: '35%' }}>Название</th>
|
||||
<th style={{ width: '15%' }}>Использований</th>
|
||||
<th style={{ width: '15%' }}>Процент</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enrichedStats.slice(0, 10).map((stat, idx) => {
|
||||
const percentage = ((stat.count / totalUsage) * 100).toFixed(1);
|
||||
return (
|
||||
<tr key={stat.community}>
|
||||
<td className="text-muted">{idx + 1}</td>
|
||||
<td>
|
||||
<code className="text-blue">{stat.community}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex align-items-center">
|
||||
<span>{stat.name || '—'}</span>
|
||||
{stat.tags && stat.tags.length > 0 && (
|
||||
<div className="ms-2">
|
||||
{stat.tags.slice(0, 2).map(tag => (
|
||||
<span key={tag} className="badge bg-blue-lt text-blue me-1" style={{ fontSize: '0.7rem' }}>
|
||||
<IconTags size={10} className="me-1" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge bg-green-lt text-green">{stat.count}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="progress flex-fill" style={{ height: 8 }}>
|
||||
<div
|
||||
className="progress-bar bg-blue"
|
||||
style={{ width: `${percentage}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={percentage}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
></div>
|
||||
</div>
|
||||
<span className="ms-2 text-muted" style={{ fontSize: '0.85rem' }}>{percentage}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика по категориям */}
|
||||
{Object.keys(statsByCategory).length > 1 && (
|
||||
<div>
|
||||
<h4 className="mb-3">По категориям</h4>
|
||||
<div className="row g-3">
|
||||
{Object.entries(statsByCategory).map(([category, items]) => {
|
||||
const categoryTotal = items.reduce((sum, item) => sum + item.count, 0);
|
||||
const categoryPercentage = ((categoryTotal / totalUsage) * 100).toFixed(1);
|
||||
return (
|
||||
<div key={category} className="col-md-6">
|
||||
<div className="card card-sm">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 className="mb-0">{category}</h5>
|
||||
<div className="text-muted small">{items.length} communities</div>
|
||||
</div>
|
||||
<span className="badge bg-blue-lt text-blue">
|
||||
{categoryTotal} использований
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress" style={{ height: 6 }}>
|
||||
<div
|
||||
className="progress-bar bg-blue"
|
||||
style={{ width: `${categoryPercentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-muted small mt-1">{categoryPercentage}% от общего</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommunityStats;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { IconTrophy, IconMapPin, IconCloud, IconHash, IconChartBar } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Компонент для отображения Top-N статистики
|
||||
*/
|
||||
function TopNStats({ data, loading }) {
|
||||
const [topCountries, setTopCountries] = useState([]);
|
||||
const [topProviders, setTopProviders] = useState([]);
|
||||
const [topCommunities, setTopCommunities] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !Array.isArray(data.servers)) return;
|
||||
|
||||
// Подсчет по странам
|
||||
const countriesMap = new Map();
|
||||
data.servers.forEach(s => {
|
||||
const country = s.country || 'Unknown';
|
||||
countriesMap.set(country, (countriesMap.get(country) || 0) + 1);
|
||||
});
|
||||
const topC = Array.from(countriesMap.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
setTopCountries(topC);
|
||||
|
||||
// Подсчет по провайдерам
|
||||
const providersMap = new Map();
|
||||
data.servers.forEach(s => {
|
||||
const provider = s.provider || 'Unknown';
|
||||
providersMap.set(provider, (providersMap.get(provider) || 0) + 1);
|
||||
});
|
||||
const topP = Array.from(providersMap.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
setTopProviders(topP);
|
||||
|
||||
}, [data]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body text-center py-4">
|
||||
<div className="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<span className="text-muted">Загрузка статистики...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getFlagEmoji = (countryCode) => {
|
||||
if (!countryCode) return '🌐';
|
||||
const map = { SWE: 'SE', UK: 'GB', RU: 'RU', US: 'US', DE: 'DE', NL: 'NL', SG: 'SG' };
|
||||
const cc = (map[countryCode.toUpperCase()] || countryCode).slice(0, 2);
|
||||
if (cc.length !== 2) return '🌐';
|
||||
return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt()));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row g-3">
|
||||
{/* Top Страны */}
|
||||
<div className="col-md-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconMapPin className="me-2" size={20} />
|
||||
Top-5 Стран
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{topCountries.length === 0 ? (
|
||||
<div className="text-center text-muted">Нет данных</div>
|
||||
) : (
|
||||
<div className="list-group list-group-flush">
|
||||
{topCountries.map((item, idx) => {
|
||||
const maxCount = topCountries[0]?.count || 1;
|
||||
const percentage = ((item.count / maxCount) * 100).toFixed(0);
|
||||
return (
|
||||
<div key={item.name} className="list-group-item px-0">
|
||||
<div className="d-flex align-items-center justify-content-between mb-1">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="me-2" style={{ fontSize: idx === 0 ? '1.5rem' : '1.2rem' }}>
|
||||
{idx === 0 ? '🏆' : getFlagEmoji(item.name)}
|
||||
</span>
|
||||
<strong>{item.name}</strong>
|
||||
</div>
|
||||
<span className="badge bg-blue-lt text-blue">{item.count}</span>
|
||||
</div>
|
||||
<div className="progress" style={{ height: 6 }}>
|
||||
<div
|
||||
className="progress-bar bg-blue"
|
||||
style={{ width: `${percentage}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={percentage}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Провайдеры */}
|
||||
<div className="col-md-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconCloud className="me-2" size={20} />
|
||||
Top-5 Провайдеров
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{topProviders.length === 0 ? (
|
||||
<div className="text-center text-muted">Нет данных</div>
|
||||
) : (
|
||||
<div className="list-group list-group-flush">
|
||||
{topProviders.map((item, idx) => {
|
||||
const maxCount = topProviders[0]?.count || 1;
|
||||
const percentage = ((item.count / maxCount) * 100).toFixed(0);
|
||||
return (
|
||||
<div key={item.name} className="list-group-item px-0">
|
||||
<div className="d-flex align-items-center justify-content-between mb-1">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="me-2" style={{ fontSize: '1.2rem' }}>
|
||||
{idx === 0 ? '🏆' : '☁️'}
|
||||
</span>
|
||||
<strong className="text-truncate" style={{ maxWidth: 200 }}>{item.name}</strong>
|
||||
</div>
|
||||
<span className="badge bg-green-lt text-green">{item.count}</span>
|
||||
</div>
|
||||
<div className="progress" style={{ height: 6 }}>
|
||||
<div
|
||||
className="progress-bar bg-green"
|
||||
style={{ width: `${percentage}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={percentage}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopNStats;
|
||||
|
||||
Reference in New Issue
Block a user