From 1fb53c34676a625ceff6a7beab34b8cf05d961cc Mon Sep 17 00:00:00 2001 From: Denis Shatskiy Date: Sun, 10 Aug 2025 23:44:17 +0700 Subject: [PATCH] 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 --- frontend/src/ASNsNewManager.jsx | 54 +++++++++- frontend/src/App.jsx | 104 ++++++++++++++++--- frontend/src/Dashboard.jsx | 44 ++++++-- frontend/src/DomainsNewManager.jsx | 56 +++++++++- frontend/src/IPRangesManager.jsx | 53 ++++++++++ frontend/src/components/ConfirmDiffModal.jsx | 39 +++++++ frontend/src/components/LockBanner.jsx | 84 +++++++++++++++ frontend/src/components/S3MetaBar.jsx | 51 +++++++++ 8 files changed, 460 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/ConfirmDiffModal.jsx create mode 100644 frontend/src/components/LockBanner.jsx create mode 100644 frontend/src/components/S3MetaBar.jsx diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index f8eca32..6de69a0 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -17,6 +17,9 @@ import { IconClock, IconFileText } 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'; @@ -193,6 +196,7 @@ function ASNsNewManager() { const [showDiff, setShowDiff] = useState(false); const [diff, setDiff] = useState({ added: [], removed: [], changed: [] }); + const [confirmSaveOpen, setConfirmSaveOpen] = useState(false); const handlePreviewDiff = () => { const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community)) @@ -203,9 +207,17 @@ function ASNsNewManager() { }; 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); try { - // API ожидает domains: [{domain, type}] 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); @@ -262,6 +274,28 @@ function ASNsNewManager() { 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 = () => { setItems(prev => prev.filter(i => i.asn || i.community) .filter(i => isValidAsn(i.asn) && isValidCommunity(i.community)) @@ -350,6 +384,7 @@ function ASNsNewManager() {
+ {/* Add New ASN Card */}
@@ -404,6 +439,16 @@ function ASNsNewManager() {
+ {/* Drag & Drop импорт */} +
{ e.preventDefault(); }} + onDrop={onDropImport} + > +
+ Перетащите файл TXT/CSV сюда для импорта (формат: ASN community) +
+
{/* Actions Card */}
@@ -501,6 +546,7 @@ function ASNsNewManager() {
+
@@ -657,6 +703,12 @@ function ASNsNewManager() { )} + setConfirmSaveOpen(false)} + /> {/* datalist больше не нужен, т.к. используем кастомный автокомплит */} ); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6ff54da..88d6e95 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, createContext, useContext } from 'react'; import { Container } from 'react-bootstrap'; import { IconBrandTabler, @@ -23,6 +23,50 @@ import CommunitiesManager from './CommunitiesManager'; import Dashboard from './Dashboard'; import './App.css'; 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 ( + + {children} + + ); +} + +// --- 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 ( + + {children} + + ); +} import { BrowserRouter as Router, Routes, @@ -35,13 +79,19 @@ import { function App() { return ( - + + + + + ); } function MainLayout() { const location = useLocation(); + const { lang, setLang, t } = useContext(LanguageContext); + const { theme, setTheme } = useContext(ThemeContext); // Навбар стал лаконичным: без неиспользуемых уведомлений/иконок // Состояние для управления выпадающими меню @@ -88,38 +138,38 @@ function MainLayout() { const navCategories = [ { id: 'home', - title: 'Главная', + title: t('home'), icon: IconHome, path: '/dashboard', single: true }, { id: 'data', - title: 'Данные', + title: t('data'), icon: IconDatabase, items: [ - { id: 'domains', title: 'Домены', path: '/domains', icon: IconWorld }, - { id: 'ip-ranges', title: 'IP-диапазоны', path: '/ip-ranges', icon: IconNetwork }, - { id: 'asns', title: 'AS', path: '/asns', icon: IconNetwork }, - { id: 'communities', title: 'Community', path: '/communities', icon: IconFilter } + { id: 'domains', title: t('domains'), path: '/domains', icon: IconWorld }, + { id: 'ip-ranges', title: t('ipRanges'), path: '/ip-ranges', icon: IconNetwork }, + { id: 'asns', title: t('asns'), path: '/asns', icon: IconNetwork }, + { id: 'communities', title: t('communities'), path: '/communities', icon: IconFilter } ] }, { id: 'management', - title: 'Управление', + title: t('management'), icon: IconServer, items: [ - { id: 'servers', title: 'Серверы', path: '/servers', icon: IconServer }, - { id: 'filters', title: 'Фильтры', path: '/filters', icon: IconFilter }, - { id: 'billing', title: 'Биллинг', path: '/billing', icon: IconCreditCard } + { id: 'servers', title: t('servers'), path: '/servers', icon: IconServer }, + { id: 'filters', title: t('filters'), path: '/filters', icon: IconFilter }, + { id: 'billing', title: t('billing'), path: '/billing', icon: IconCreditCard } ] }, { id: 'tools', - title: 'Инструменты', + title: t('tools'), icon: IconSettings, 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() { - {/* Правая часть навбара убрана по требованиям лаконичности */} + {/* Правая часть навбара: язык и тема */} +
+
+ +
+
+ +
+
diff --git a/frontend/src/Dashboard.jsx b/frontend/src/Dashboard.jsx index d445248..14ffd64 100644 --- a/frontend/src/Dashboard.jsx +++ b/frontend/src/Dashboard.jsx @@ -13,7 +13,8 @@ import { IconRefresh, IconFilter, IconDownload, - IconCreditCard + IconCreditCard, + IconSearch } from '@tabler/icons-react'; function StatCard({ icon: Icon, color, value, title, subtitle, to }) { @@ -56,6 +57,7 @@ function MetricCard({ title, value, icon: Icon, color, description }) { } function Dashboard() { + const [globalQuery, setGlobalQuery] = useState(''); const [stats, setStats] = useState({ domainsCount: null, ipRangesCount: null, @@ -67,6 +69,7 @@ function Dashboard() { onlineServers: null, totalServers: null }); + const [raw, setRaw] = useState({ domains: [], ipRanges: [], asns: [], servers: [] }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -92,7 +95,11 @@ function Dashboard() { 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 : []; + 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 = servers.filter(server => server.status === 'Онлайн').length; @@ -103,9 +110,9 @@ function Dashboard() { const lastModified = lmRaw ? new Date(lmRaw).toLocaleString() : new Date().toLocaleString(); setStats({ - domainsCount: domainsRes.status === 'fulfilled' ? domainsRes.value.data.length : 0, - ipRangesCount: ipRangesRes.status === 'fulfilled' ? ipRangesRes.value.data.length : 0, - asnsCount: asnsRes.status === 'fulfilled' ? asnsRes.value.data.length : 0, + domainsCount: domains.length, + ipRangesCount: ipRanges.length, + asnsCount: asns.length, serversCount: servers.length, lastModified, countriesCount: countries.size, @@ -151,6 +158,19 @@ function Dashboard() { return (
+ {/* Глобальный поиск */} +
+
+ + setGlobalQuery(e.target.value)} + /> +
+
{/* Заголовок страницы */}
@@ -172,7 +192,9 @@ function Dashboard() { `${d.domain} ${d.community || d.type || ''}`.toLowerCase().includes(globalQuery.toLowerCase())).length + : (stats.domainsCount ?? '—'))} title="Доменов" subtitle="Всего доменов в системе" to="/domains" @@ -182,7 +204,9 @@ function Dashboard() { `${x.ipRange} ${x.community || ''}`.toLowerCase().includes(globalQuery.toLowerCase())).length + : (stats.ipRangesCount ?? '—'))} title="IP-диапазонов" subtitle="Всего IP диапазонов" to="/ip-ranges" @@ -192,7 +216,9 @@ function Dashboard() { `${a.domain || a.asn} ${a.type || a.community || ''}`.toLowerCase().includes(globalQuery.toLowerCase())).length + : (stats.asnsCount ?? '—'))} title="AS" subtitle="Всего Autonomous Systems" to="/asns" @@ -202,7 +228,9 @@ function Dashboard() { `${s.ip} ${s.dns} ${s.country} ${s.provider}`.toLowerCase().includes(globalQuery.toLowerCase())).length + : (stats.serversCount ?? '—'))} title="Серверов" subtitle="Всего серверов" to="/servers" diff --git a/frontend/src/DomainsNewManager.jsx b/frontend/src/DomainsNewManager.jsx index c1c08a2..4b86894 100644 --- a/frontend/src/DomainsNewManager.jsx +++ b/frontend/src/DomainsNewManager.jsx @@ -17,6 +17,9 @@ import { IconClock, IconFileText } 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'; @@ -197,6 +200,7 @@ function DomainsNewManager() { const [showDiff, setShowDiff] = useState(false); const [diff, setDiff] = useState({ added: [], removed: [], changed: [] }); + const [confirmSaveOpen, setConfirmSaveOpen] = useState(false); const handlePreviewDiff = () => { const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community)) @@ -207,9 +211,18 @@ function DomainsNewManager() { }; 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); try { - // сохраняем только валидные строки + дедупликация 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); @@ -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 text = window.prompt('Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY (по одной записи на строку)'); if (!text) return; @@ -356,6 +391,7 @@ function DomainsNewManager() {
+ {/* Add New Item Card */}
@@ -479,6 +515,16 @@ function DomainsNewManager() {
+ {/* Drag & Drop импорт */} +
{ e.preventDefault(); }} + onDrop={onDropImport} + > +
+ Перетащите файл TXT/CSV сюда для импорта (формат: domain community) +
+
@@ -508,6 +554,7 @@ function DomainsNewManager() {
+
@@ -664,6 +711,13 @@ function DomainsNewManager() { )} + {/* Confirm Save Modal */} + setConfirmSaveOpen(false)} + /> {/* Diff Modal */} {showDiff && (
diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx index 8dac85c..1ea6f90 100644 --- a/frontend/src/IPRangesManager.jsx +++ b/frontend/src/IPRangesManager.jsx @@ -17,6 +17,9 @@ import { IconClock, IconFileText } 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'; @@ -208,6 +211,7 @@ function IPRangesManager() { const [showDiff, setShowDiff] = useState(false); const [diff, setDiff] = useState({ added: [], removed: [], changed: [] }); + const [confirmSaveOpen, setConfirmSaveOpen] = useState(false); const handlePreviewDiff = () => { const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community)) @@ -218,6 +222,15 @@ function IPRangesManager() { }; 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); try { const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community)) @@ -277,6 +290,28 @@ function IPRangesManager() { 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 = () => { setItems(prev => prev.filter(i => i.ipRange || i.community) .filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community)) @@ -365,6 +400,7 @@ function IPRangesManager() {
+ {/* Add New Item Card */}
@@ -419,6 +455,16 @@ function IPRangesManager() {
+ {/* Drag & Drop импорт */} +
{ e.preventDefault(); }} + onDrop={onDropImport} + > +
+ Перетащите файл TXT/CSV сюда для импорта (формат: CIDR community) +
+
{/* Actions Card */}
@@ -516,6 +562,7 @@ function IPRangesManager() {
+
@@ -677,6 +724,12 @@ function IPRangesManager() { )} + setConfirmSaveOpen(false)} + /> {/* Diff Modal */} {showDiff && (
diff --git a/frontend/src/components/ConfirmDiffModal.jsx b/frontend/src/components/ConfirmDiffModal.jsx new file mode 100644 index 0000000..15ea0b0 --- /dev/null +++ b/frontend/src/components/ConfirmDiffModal.jsx @@ -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 ( +
+
+
+
+
Подтвердить сохранение
+ +
+
+
+
+
Добавлено
{added}
+
+
+
Удалено
{removed}
+
+
+
Изменено
{changed}
+
+
+
+
+ + +
+
+
+
+ ); +} + +export default ConfirmDiffModal; + + diff --git a/frontend/src/components/LockBanner.jsx b/frontend/src/components/LockBanner.jsx new file mode 100644 index 0000000..48b6881 --- /dev/null +++ b/frontend/src/components/LockBanner.jsx @@ -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 ( +
+
+ {status.locked ? ( + myLock ? ( + <>Вы удерживаете блокировку ресурса {resource}{until ? ` до ${until}` : ''} + ) : ( + <>Ресурс {resource} редактирует {status.owner || 'другой пользователь'}{until ? ` до ${until}` : ''} + ) + ) : ( + <>Ресурс {resource} свободен + )} +
+
+ + + {myLock && ( + + )} +
+
+ ); +} + +export default LockBanner; + + diff --git a/frontend/src/components/S3MetaBar.jsx b/frontend/src/components/S3MetaBar.jsx new file mode 100644 index 0000000..c9ba546 --- /dev/null +++ b/frontend/src/components/S3MetaBar.jsx @@ -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 ( +
+ {it?.etag || '—'} + {it?.lastModified ? new Date(it.lastModified).toLocaleString() : '—'} + {typeof it?.contentLength === 'number' ? `${it.contentLength} байт` : '—'} +
+ ); + }; + + return ( +
+
+
+ {keys.length === 0 ? renderKey('domainsNew') : keys.map(renderKey)} +
+ +
+
+ ); +} + +export default S3MetaBar; + +