feat: Add language and theme context providers to App component, implement global search functionality in Dashboard, and enhance ASNs, Domains, and IPRanges managers with drag-and-drop import feature and confirmation modals for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m48s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m48s
This commit is contained in:
@@ -17,6 +17,9 @@ import {
|
|||||||
IconClock,
|
IconClock,
|
||||||
IconFileText
|
IconFileText
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import LockBanner from './components/LockBanner.jsx';
|
||||||
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
|
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -193,6 +196,7 @@ function ASNsNewManager() {
|
|||||||
|
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
|
|
||||||
const handlePreviewDiff = () => {
|
const handlePreviewDiff = () => {
|
||||||
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||||
@@ -203,9 +207,17 @@ function ASNsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||||
|
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
||||||
|
const unique = deduplicate(valid);
|
||||||
|
setDiff(computeDiff(originalItems, unique));
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const performSave = async () => {
|
||||||
|
setConfirmSaveOpen(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// API ожидает domains: [{domain, type}]
|
|
||||||
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||||
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
@@ -262,6 +274,28 @@ function ASNsNewManager() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drag&Drop импорт
|
||||||
|
const onDropImport = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||||
|
const parsed = lines.map(l => {
|
||||||
|
const [a, c] = l.split(/\s+/);
|
||||||
|
return { asn: (a || '').trim(), community: (c || '').trim() };
|
||||||
|
}).filter(i => isValidAsn(i.asn) && isValidCommunity(i.community));
|
||||||
|
setItems(prev => {
|
||||||
|
const merged = [...prev, ...parsed];
|
||||||
|
const map = new Map();
|
||||||
|
for (const it of merged) {
|
||||||
|
const key = String(it.asn).trim();
|
||||||
|
if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() });
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const clearInvalid = () => {
|
const clearInvalid = () => {
|
||||||
setItems(prev => prev.filter(i => i.asn || i.community)
|
setItems(prev => prev.filter(i => i.asn || i.community)
|
||||||
.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||||
@@ -350,6 +384,7 @@ function ASNsNewManager() {
|
|||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
|
<LockBanner resource="asns" className="mb-3" />
|
||||||
{/* Add New ASN Card */}
|
{/* Add New ASN Card */}
|
||||||
<div className="card card-md">
|
<div className="card card-md">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
@@ -404,6 +439,16 @@ function ASNsNewManager() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Drag & Drop импорт */}
|
||||||
|
<div
|
||||||
|
className="card card-md border-dashed"
|
||||||
|
onDragOver={(e) => { e.preventDefault(); }}
|
||||||
|
onDrop={onDropImport}
|
||||||
|
>
|
||||||
|
<div className="card-body text-center text-muted">
|
||||||
|
Перетащите файл TXT/CSV сюда для импорта (формат: ASN community)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions Card */}
|
{/* Actions Card */}
|
||||||
<div className="card card-md">
|
<div className="card card-md">
|
||||||
@@ -501,6 +546,7 @@ function ASNsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<S3MetaBar keys={["asns"]} />
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -657,6 +703,12 @@ function ASNsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDiffModal
|
||||||
|
show={confirmSaveOpen}
|
||||||
|
diff={diff}
|
||||||
|
onConfirm={performSave}
|
||||||
|
onClose={() => setConfirmSaveOpen(false)}
|
||||||
|
/>
|
||||||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+89
-15
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, createContext, useContext } from 'react';
|
||||||
import { Container } from 'react-bootstrap';
|
import { Container } from 'react-bootstrap';
|
||||||
import {
|
import {
|
||||||
IconBrandTabler,
|
IconBrandTabler,
|
||||||
@@ -23,6 +23,50 @@ import CommunitiesManager from './CommunitiesManager';
|
|||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// --- Simple i18n (RU/EN) ---
|
||||||
|
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
|
||||||
|
|
||||||
|
function LanguageProvider({ children }) {
|
||||||
|
const [lang, setLang] = useState(() => localStorage.getItem('lang') || 'ru');
|
||||||
|
useEffect(() => { localStorage.setItem('lang', lang); }, [lang]);
|
||||||
|
const dict = {
|
||||||
|
ru: {
|
||||||
|
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
||||||
|
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||||
|
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||||
|
light: 'Светлая', dark: 'Тёмная'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
||||||
|
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
||||||
|
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
||||||
|
light: 'Light', dark: 'Dark'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const t = (key) => (dict[lang] && key in dict[lang] ? dict[lang][key] : key);
|
||||||
|
return (
|
||||||
|
<LanguageContext.Provider value={{ lang, setLang, t }}>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Theme provider (light/dark) ---
|
||||||
|
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
|
||||||
|
|
||||||
|
function ThemeProvider({ children }) {
|
||||||
|
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
import {
|
import {
|
||||||
BrowserRouter as Router,
|
BrowserRouter as Router,
|
||||||
Routes,
|
Routes,
|
||||||
@@ -35,13 +79,19 @@ import {
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<MainLayout />
|
<LanguageProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<MainLayout />
|
||||||
|
</ThemeProvider>
|
||||||
|
</LanguageProvider>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MainLayout() {
|
function MainLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { lang, setLang, t } = useContext(LanguageContext);
|
||||||
|
const { theme, setTheme } = useContext(ThemeContext);
|
||||||
// Навбар стал лаконичным: без неиспользуемых уведомлений/иконок
|
// Навбар стал лаконичным: без неиспользуемых уведомлений/иконок
|
||||||
|
|
||||||
// Состояние для управления выпадающими меню
|
// Состояние для управления выпадающими меню
|
||||||
@@ -88,38 +138,38 @@ function MainLayout() {
|
|||||||
const navCategories = [
|
const navCategories = [
|
||||||
{
|
{
|
||||||
id: 'home',
|
id: 'home',
|
||||||
title: 'Главная',
|
title: t('home'),
|
||||||
icon: IconHome,
|
icon: IconHome,
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
single: true
|
single: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'data',
|
id: 'data',
|
||||||
title: 'Данные',
|
title: t('data'),
|
||||||
icon: IconDatabase,
|
icon: IconDatabase,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'domains', title: 'Домены', path: '/domains', icon: IconWorld },
|
{ id: 'domains', title: t('domains'), path: '/domains', icon: IconWorld },
|
||||||
{ id: 'ip-ranges', title: 'IP-диапазоны', path: '/ip-ranges', icon: IconNetwork },
|
{ id: 'ip-ranges', title: t('ipRanges'), path: '/ip-ranges', icon: IconNetwork },
|
||||||
{ id: 'asns', title: 'AS', path: '/asns', icon: IconNetwork },
|
{ id: 'asns', title: t('asns'), path: '/asns', icon: IconNetwork },
|
||||||
{ id: 'communities', title: 'Community', path: '/communities', icon: IconFilter }
|
{ id: 'communities', title: t('communities'), path: '/communities', icon: IconFilter }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'management',
|
id: 'management',
|
||||||
title: 'Управление',
|
title: t('management'),
|
||||||
icon: IconServer,
|
icon: IconServer,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'servers', title: 'Серверы', path: '/servers', icon: IconServer },
|
{ id: 'servers', title: t('servers'), path: '/servers', icon: IconServer },
|
||||||
{ id: 'filters', title: 'Фильтры', path: '/filters', icon: IconFilter },
|
{ id: 'filters', title: t('filters'), path: '/filters', icon: IconFilter },
|
||||||
{ id: 'billing', title: 'Биллинг', path: '/billing', icon: IconCreditCard }
|
{ id: 'billing', title: t('billing'), path: '/billing', icon: IconCreditCard }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tools',
|
id: 'tools',
|
||||||
title: 'Инструменты',
|
title: t('tools'),
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'auto-urls', title: 'Авто URL', path: '/auto-urls', icon: IconDownload }
|
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// Убрали неиспользуемые/неработающие разделы
|
// Убрали неиспользуемые/неработающие разделы
|
||||||
@@ -211,7 +261,31 @@ function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Правая часть навбара убрана по требованиям лаконичности */}
|
{/* Правая часть навбара: язык и тема */}
|
||||||
|
<div className="navbar-nav flex-row order-md-last">
|
||||||
|
<div className="nav-item me-2">
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => setLang(e.target.value)}
|
||||||
|
aria-label="language"
|
||||||
|
>
|
||||||
|
<option value="ru">RU</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="nav-item">
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => setTheme(e.target.value)}
|
||||||
|
aria-label="theme"
|
||||||
|
>
|
||||||
|
<option value="light">{t('light')}</option>
|
||||||
|
<option value="dark">{t('dark')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="container-xl mt-4">
|
<div className="container-xl mt-4">
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconFilter,
|
IconFilter,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconCreditCard
|
IconCreditCard,
|
||||||
|
IconSearch
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
function StatCard({ icon: Icon, color, value, title, subtitle, to }) {
|
function StatCard({ icon: Icon, color, value, title, subtitle, to }) {
|
||||||
@@ -56,6 +57,7 @@ function MetricCard({ title, value, icon: Icon, color, description }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
|
const [globalQuery, setGlobalQuery] = useState('');
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
domainsCount: null,
|
domainsCount: null,
|
||||||
ipRangesCount: null,
|
ipRangesCount: null,
|
||||||
@@ -67,6 +69,7 @@ function Dashboard() {
|
|||||||
onlineServers: null,
|
onlineServers: null,
|
||||||
totalServers: null
|
totalServers: null
|
||||||
});
|
});
|
||||||
|
const [raw, setRaw] = useState({ domains: [], ipRanges: [], asns: [], servers: [] });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
@@ -92,7 +95,11 @@ function Dashboard() {
|
|||||||
const s3Res = results[4];
|
const s3Res = results[4];
|
||||||
|
|
||||||
// Получаем данные серверов для подсчета дополнительной статистики
|
// Получаем данные серверов для подсчета дополнительной статистики
|
||||||
|
const domains = domainsRes.status === 'fulfilled' ? domainsRes.value.data : [];
|
||||||
|
const ipRanges = ipRangesRes.status === 'fulfilled' ? ipRangesRes.value.data : [];
|
||||||
|
const asns = asnsRes.status === 'fulfilled' ? asnsRes.value.data : [];
|
||||||
const servers = serversRes.status === 'fulfilled' ? serversRes.value.data : [];
|
const servers = serversRes.status === 'fulfilled' ? serversRes.value.data : [];
|
||||||
|
setRaw({ domains, ipRanges, asns, servers });
|
||||||
const countries = new Set(servers.map(server => server.country).filter(Boolean));
|
const countries = new Set(servers.map(server => server.country).filter(Boolean));
|
||||||
const providers = new Set(servers.map(server => server.provider).filter(Boolean));
|
const providers = new Set(servers.map(server => server.provider).filter(Boolean));
|
||||||
const onlineServers = servers.filter(server => server.status === 'Онлайн').length;
|
const onlineServers = servers.filter(server => server.status === 'Онлайн').length;
|
||||||
@@ -103,9 +110,9 @@ function Dashboard() {
|
|||||||
const lastModified = lmRaw ? new Date(lmRaw).toLocaleString() : new Date().toLocaleString();
|
const lastModified = lmRaw ? new Date(lmRaw).toLocaleString() : new Date().toLocaleString();
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
domainsCount: domainsRes.status === 'fulfilled' ? domainsRes.value.data.length : 0,
|
domainsCount: domains.length,
|
||||||
ipRangesCount: ipRangesRes.status === 'fulfilled' ? ipRangesRes.value.data.length : 0,
|
ipRangesCount: ipRanges.length,
|
||||||
asnsCount: asnsRes.status === 'fulfilled' ? asnsRes.value.data.length : 0,
|
asnsCount: asns.length,
|
||||||
serversCount: servers.length,
|
serversCount: servers.length,
|
||||||
lastModified,
|
lastModified,
|
||||||
countriesCount: countries.size,
|
countriesCount: countries.size,
|
||||||
@@ -151,6 +158,19 @@ function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Глобальный поиск */}
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-body d-flex align-items-center">
|
||||||
|
<span className="input-icon-addon me-2"><IconSearch size={18} /></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Глобальный поиск по доменам, ASN, IP, серверам..."
|
||||||
|
value={globalQuery}
|
||||||
|
onChange={(e) => setGlobalQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/* Заголовок страницы */}
|
{/* Заголовок страницы */}
|
||||||
<div className="page-header d-print-none mb-4">
|
<div className="page-header d-print-none mb-4">
|
||||||
<div className="row align-items-center">
|
<div className="row align-items-center">
|
||||||
@@ -172,7 +192,9 @@ function Dashboard() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={IconWorld}
|
icon={IconWorld}
|
||||||
color="blue"
|
color="blue"
|
||||||
value={loading ? '...' : stats.domainsCount ?? '—'}
|
value={loading ? '...' : (globalQuery
|
||||||
|
? raw.domains.filter(d => `${d.domain} ${d.community || d.type || ''}`.toLowerCase().includes(globalQuery.toLowerCase())).length
|
||||||
|
: (stats.domainsCount ?? '—'))}
|
||||||
title="Доменов"
|
title="Доменов"
|
||||||
subtitle="Всего доменов в системе"
|
subtitle="Всего доменов в системе"
|
||||||
to="/domains"
|
to="/domains"
|
||||||
@@ -182,7 +204,9 @@ function Dashboard() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={IconNetwork}
|
icon={IconNetwork}
|
||||||
color="green"
|
color="green"
|
||||||
value={loading ? '...' : stats.ipRangesCount ?? '—'}
|
value={loading ? '...' : (globalQuery
|
||||||
|
? raw.ipRanges.filter(x => `${x.ipRange} ${x.community || ''}`.toLowerCase().includes(globalQuery.toLowerCase())).length
|
||||||
|
: (stats.ipRangesCount ?? '—'))}
|
||||||
title="IP-диапазонов"
|
title="IP-диапазонов"
|
||||||
subtitle="Всего IP диапазонов"
|
subtitle="Всего IP диапазонов"
|
||||||
to="/ip-ranges"
|
to="/ip-ranges"
|
||||||
@@ -192,7 +216,9 @@ function Dashboard() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={IconNetwork}
|
icon={IconNetwork}
|
||||||
color="purple"
|
color="purple"
|
||||||
value={loading ? '...' : stats.asnsCount ?? '—'}
|
value={loading ? '...' : (globalQuery
|
||||||
|
? raw.asns.filter(a => `${a.domain || a.asn} ${a.type || a.community || ''}`.toLowerCase().includes(globalQuery.toLowerCase())).length
|
||||||
|
: (stats.asnsCount ?? '—'))}
|
||||||
title="AS"
|
title="AS"
|
||||||
subtitle="Всего Autonomous Systems"
|
subtitle="Всего Autonomous Systems"
|
||||||
to="/asns"
|
to="/asns"
|
||||||
@@ -202,7 +228,9 @@ function Dashboard() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={IconServer}
|
icon={IconServer}
|
||||||
color="orange"
|
color="orange"
|
||||||
value={loading ? '...' : stats.serversCount ?? '—'}
|
value={loading ? '...' : (globalQuery
|
||||||
|
? raw.servers.filter(s => `${s.ip} ${s.dns} ${s.country} ${s.provider}`.toLowerCase().includes(globalQuery.toLowerCase())).length
|
||||||
|
: (stats.serversCount ?? '—'))}
|
||||||
title="Серверов"
|
title="Серверов"
|
||||||
subtitle="Всего серверов"
|
subtitle="Всего серверов"
|
||||||
to="/servers"
|
to="/servers"
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
IconClock,
|
IconClock,
|
||||||
IconFileText
|
IconFileText
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import LockBanner from './components/LockBanner.jsx';
|
||||||
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
|
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -197,6 +200,7 @@ function DomainsNewManager() {
|
|||||||
|
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
|
|
||||||
const handlePreviewDiff = () => {
|
const handlePreviewDiff = () => {
|
||||||
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||||
@@ -207,9 +211,18 @@ function DomainsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
|
// подготовим diff и спросим подтверждение
|
||||||
|
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||||
|
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||||||
|
const unique = deduplicate(valid);
|
||||||
|
setDiff(computeDiff(originalItems, unique));
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const performSave = async () => {
|
||||||
|
setConfirmSaveOpen(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// сохраняем только валидные строки + дедупликация
|
|
||||||
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
@@ -234,6 +247,28 @@ function DomainsNewManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drag&Drop импорт
|
||||||
|
const onDropImport = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||||
|
const parsed = lines.map(l => {
|
||||||
|
const [d, c] = l.split(/\s+/);
|
||||||
|
return { domain: (d || '').toLowerCase(), community: (c || '').trim() };
|
||||||
|
}).filter(i => isValidDomain(i.domain) && isValidCommunity(i.community));
|
||||||
|
setItems(prev => {
|
||||||
|
const merged = [...prev, ...parsed];
|
||||||
|
const map = new Map();
|
||||||
|
for (const it of merged) {
|
||||||
|
const key = String(it.domain).trim().toLowerCase();
|
||||||
|
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
const text = window.prompt('Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY (по одной записи на строку)');
|
const text = window.prompt('Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY (по одной записи на строку)');
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -356,6 +391,7 @@ function DomainsNewManager() {
|
|||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
|
<LockBanner resource="domains-new" className="mb-3" />
|
||||||
{/* Add New Item Card */}
|
{/* Add New Item Card */}
|
||||||
<div className="card card-md">
|
<div className="card card-md">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
@@ -479,6 +515,16 @@ function DomainsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Drag & Drop импорт */}
|
||||||
|
<div
|
||||||
|
className="card card-md border-dashed"
|
||||||
|
onDragOver={(e) => { e.preventDefault(); }}
|
||||||
|
onDrop={onDropImport}
|
||||||
|
>
|
||||||
|
<div className="card-body text-center text-muted">
|
||||||
|
Перетащите файл TXT/CSV сюда для импорта (формат: domain community)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-lg-9">
|
<div className="col-lg-9">
|
||||||
@@ -508,6 +554,7 @@ function DomainsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<S3MetaBar keys={["domainsNew"]} />
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -664,6 +711,13 @@ function DomainsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Confirm Save Modal */}
|
||||||
|
<ConfirmDiffModal
|
||||||
|
show={confirmSaveOpen}
|
||||||
|
diff={diff}
|
||||||
|
onConfirm={performSave}
|
||||||
|
onClose={() => setConfirmSaveOpen(false)}
|
||||||
|
/>
|
||||||
{/* Diff Modal */}
|
{/* Diff Modal */}
|
||||||
{showDiff && (
|
{showDiff && (
|
||||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
IconClock,
|
IconClock,
|
||||||
IconFileText
|
IconFileText
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import LockBanner from './components/LockBanner.jsx';
|
||||||
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
|
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -208,6 +211,7 @@ function IPRangesManager() {
|
|||||||
|
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
|
|
||||||
const handlePreviewDiff = () => {
|
const handlePreviewDiff = () => {
|
||||||
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
@@ -218,6 +222,15 @@ function IPRangesManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
|
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
|
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
|
||||||
|
const unique = deduplicate(valid);
|
||||||
|
setDiff(computeDiff(originalItems, unique));
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const performSave = async () => {
|
||||||
|
setConfirmSaveOpen(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
@@ -277,6 +290,28 @@ function IPRangesManager() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drag&Drop импорт
|
||||||
|
const onDropImport = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||||
|
const parsed = lines.map(l => {
|
||||||
|
const [cidr, c] = l.split(/\s+/);
|
||||||
|
return { ipRange: (cidr || '').trim(), community: (c || '').trim() };
|
||||||
|
}).filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community));
|
||||||
|
setItems(prev => {
|
||||||
|
const merged = [...prev, ...parsed];
|
||||||
|
const map = new Map();
|
||||||
|
for (const it of merged) {
|
||||||
|
const key = String(it.ipRange).trim();
|
||||||
|
if (!map.has(key)) map.set(key, { ipRange: key, community: String(it.community).trim() });
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const clearInvalid = () => {
|
const clearInvalid = () => {
|
||||||
setItems(prev => prev.filter(i => i.ipRange || i.community)
|
setItems(prev => prev.filter(i => i.ipRange || i.community)
|
||||||
.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
@@ -365,6 +400,7 @@ function IPRangesManager() {
|
|||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
|
<LockBanner resource="ip-ranges" className="mb-3" />
|
||||||
{/* Add New Item Card */}
|
{/* Add New Item Card */}
|
||||||
<div className="card card-md">
|
<div className="card card-md">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
@@ -419,6 +455,16 @@ function IPRangesManager() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Drag & Drop импорт */}
|
||||||
|
<div
|
||||||
|
className="card card-md border-dashed"
|
||||||
|
onDragOver={(e) => { e.preventDefault(); }}
|
||||||
|
onDrop={onDropImport}
|
||||||
|
>
|
||||||
|
<div className="card-body text-center text-muted">
|
||||||
|
Перетащите файл TXT/CSV сюда для импорта (формат: CIDR community)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions Card */}
|
{/* Actions Card */}
|
||||||
<div className="card card-md">
|
<div className="card card-md">
|
||||||
@@ -516,6 +562,7 @@ function IPRangesManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<S3MetaBar keys={["ipRanges"]} />
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -677,6 +724,12 @@ function IPRangesManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDiffModal
|
||||||
|
show={confirmSaveOpen}
|
||||||
|
diff={diff}
|
||||||
|
onConfirm={performSave}
|
||||||
|
onClose={() => setConfirmSaveOpen(false)}
|
||||||
|
/>
|
||||||
{/* Diff Modal */}
|
{/* Diff Modal */}
|
||||||
{showDiff && (
|
{showDiff && (
|
||||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
function ConfirmDiffModal({ show, diff, onConfirm, onClose }) {
|
||||||
|
if (!show) return null;
|
||||||
|
const added = diff?.added?.length || 0;
|
||||||
|
const removed = diff?.removed?.length || 0;
|
||||||
|
const changed = diff?.changed?.length || 0;
|
||||||
|
return (
|
||||||
|
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Подтвердить сохранение</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="row g-2 text-center">
|
||||||
|
<div className="col">
|
||||||
|
<div className="card"><div className="card-body p-2"><strong>Добавлено</strong><div className="text-muted">{added}</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="card"><div className="card-body p-2"><strong>Удалено</strong><div className="text-muted">{removed}</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="card"><div className="card-body p-2"><strong>Изменено</strong><div className="text-muted">{changed}</div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>Отмена</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={onConfirm}>Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmDiffModal;
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
function LockBanner({ resource, className = '' }) {
|
||||||
|
const [status, setStatus] = useState({ locked: false });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const owner = (() => {
|
||||||
|
const existing = localStorage.getItem('uiOwner');
|
||||||
|
if (existing) return existing;
|
||||||
|
const gen = `ui-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
localStorage.setItem('uiOwner', gen);
|
||||||
|
return gen;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/locks/${resource}`);
|
||||||
|
setStatus(res.data || { locked: false });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
const id = setInterval(fetchStatus, 30_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [resource]);
|
||||||
|
|
||||||
|
const acquireOrRefresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`/api/locks/${resource}`, { owner, ttlSeconds: 180 });
|
||||||
|
setStatus(res.data || { locked: true, owner, expiresAt: Date.now() + 180_000 });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/locks/${resource}`);
|
||||||
|
setStatus({ locked: false });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const myLock = status.locked && status.owner === owner;
|
||||||
|
const until = status.expiresAt ? new Date(status.expiresAt).toLocaleTimeString() : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`alert ${status.locked ? (myLock ? 'alert-success' : 'alert-warning') : 'alert-secondary'} d-flex align-items-center justify-content-between ${className}`} role="alert">
|
||||||
|
<div>
|
||||||
|
{status.locked ? (
|
||||||
|
myLock ? (
|
||||||
|
<>Вы удерживаете блокировку ресурса <code>{resource}</code>{until ? ` до ${until}` : ''}</>
|
||||||
|
) : (
|
||||||
|
<>Ресурс <code>{resource}</code> редактирует <strong>{status.owner || 'другой пользователь'}</strong>{until ? ` до ${until}` : ''}</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>Ресурс <code>{resource}</code> свободен</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="btn-list m-0">
|
||||||
|
<button className="btn btn-outline-primary btn-sm" onClick={fetchStatus} disabled={loading}>Обновить</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={acquireOrRefresh} disabled={loading}>{myLock ? 'Продлить' : 'Захватить'}</button>
|
||||||
|
{myLock && (
|
||||||
|
<button className="btn btn-outline-danger btn-sm" onClick={release} disabled={loading}>Освободить</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LockBanner;
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { IconHash, IconClock, IconFileText, IconRefresh } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
function S3MetaBar({ keys = [], className = '' }) {
|
||||||
|
const [meta, setMeta] = useState({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchMeta = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/s3/last-modified');
|
||||||
|
setMeta(res.data || {});
|
||||||
|
} catch {
|
||||||
|
setMeta({});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchMeta(); }, []);
|
||||||
|
|
||||||
|
const renderKey = (k) => {
|
||||||
|
const it = meta[k];
|
||||||
|
return (
|
||||||
|
<div key={k} className="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span className="badge bg-blue-lt text-blue d-inline-flex align-items-center"><IconHash size={14} className="me-1" />{it?.etag || '—'}</span>
|
||||||
|
<span className="badge bg-blue-lt text-blue d-inline-flex align-items-center"><IconClock size={14} className="me-1" />{it?.lastModified ? new Date(it.lastModified).toLocaleString() : '—'}</span>
|
||||||
|
<span className="badge bg-blue-lt text-blue d-inline-flex align-items-center"><IconFileText size={14} className="me-1" />{typeof it?.contentLength === 'number' ? `${it.contentLength} байт` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`card-footer ${className}`}>
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
{keys.length === 0 ? renderKey('domainsNew') : keys.map(renderKey)}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-outline-secondary btn-sm" onClick={fetchMeta} disabled={loading}>
|
||||||
|
<IconRefresh className={loading ? 'spin' : ''} />
|
||||||
|
<span className="ms-1">Обновить</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default S3MetaBar;
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user