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 (
+
+
+
+
+
Подтвердить сохранение
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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;
+
+