feat: Обновить серверную часть для улучшения обработки запросов и добавления новых метрик; интегрировать поддержку WebSocket URL в менеджеры ASNs, Domains и IPRanges, а также обновить интерфейс настроек для ввода WebSocket URL
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s

This commit is contained in:
2025-08-27 14:46:28 +07:00
parent ce62dad844
commit 49d08dbd14
6 changed files with 152 additions and 30 deletions
+17 -5
View File
@@ -39,6 +39,7 @@ function ASNsNewManager() {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const [editingAsn, setEditingAsn] = useState(null);
const [editingValue, setEditingValue] = useState('');
const [loading, setLoading] = useState(false);
@@ -81,6 +82,9 @@ function ASNsNewManager() {
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState('');
useEffect(() => {
// Acquire soft lock
const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`;
@@ -92,6 +96,10 @@ function ASNsNewManager() {
acquire();
const interval = setInterval(acquire, 60_000);
fetchItems();
// Load WS URL once
(async () => {
try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {}
})();
return () => {
clearInterval(interval);
api.delete(`/locks/${resource}`).catch(() => {});
@@ -123,7 +131,11 @@ function ASNsNewManager() {
};
useEffect(() => {
fetchItems();
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
fetchItems();
}, 350);
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, searchTerm, filterCommunity]);
@@ -213,7 +225,6 @@ function ASNsNewManager() {
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [wsOpen, setWsOpen] = useState(false);
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
const handlePreviewDiff = () => {
@@ -527,6 +538,7 @@ function ASNsNewManager() {
placeholder="Поиск ASN..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
aria-label="Поиск ASN"
/>
</div>
</div>
@@ -578,8 +590,8 @@ function ASNsNewManager() {
</>
) : (
<>
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
</>
)}
</td>
@@ -693,7 +705,7 @@ function ASNsNewManager() {
/>
<WsUpdateModal
show={wsOpen}
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
url={wsUrl}
onClose={() => setWsOpen(false)}
/>
<ConfirmDialog
+18 -7
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, createContext, useContext } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
IconBrandTabler,
IconWorld,
@@ -79,15 +80,25 @@ import {
import { IconMenu2 } from '@tabler/icons-react';
function App() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 30_000,
}
}
});
return (
<Router>
<LanguageProvider>
<ThemeProvider>
<NotifyProvider>
<MainLayout />
</NotifyProvider>
</ThemeProvider>
</LanguageProvider>
<QueryClientProvider client={queryClient}>
<LanguageProvider>
<ThemeProvider>
<NotifyProvider>
<MainLayout />
</NotifyProvider>
</ThemeProvider>
</LanguageProvider>
</QueryClientProvider>
</Router>
);
}
+14 -5
View File
@@ -39,6 +39,7 @@ function DomainsNewManager() {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const [editingDomain, setEditingDomain] = useState(null);
const [editingValue, setEditingValue] = useState('');
const [loading, setLoading] = useState(false);
@@ -86,6 +87,9 @@ function DomainsNewManager() {
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState('');
useEffect(() => {
// Acquire soft lock
const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`;
@@ -97,6 +101,7 @@ function DomainsNewManager() {
acquire();
const interval = setInterval(acquire, 60_000);
fetchItems();
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
return () => {
clearInterval(interval);
api.delete(`/locks/${resource}`).catch(() => {});
@@ -128,7 +133,11 @@ function DomainsNewManager() {
// Подгрузка при изменении страницы/поиска/фильтра
useEffect(() => {
fetchItems();
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
fetchItems();
}, 350);
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, searchTerm, filterCommunity]);
@@ -218,7 +227,6 @@ function DomainsNewManager() {
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [wsOpen, setWsOpen] = useState(false);
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
const handlePreviewDiff = () => {
@@ -536,6 +544,7 @@ function DomainsNewManager() {
placeholder="Поиск домена..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
aria-label="Поиск домена"
/>
</div>
</div>
@@ -587,8 +596,8 @@ function DomainsNewManager() {
</>
) : (
<>
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
</>
)}
</td>
@@ -703,7 +712,7 @@ function DomainsNewManager() {
/>
<WsUpdateModal
show={wsOpen}
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
url={wsUrl}
onClose={() => setWsOpen(false)}
/>
<ConfirmDialog
+14 -5
View File
@@ -39,6 +39,7 @@ function IPRangesManager() {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const [editingIp, setEditingIp] = useState(null);
const [editingValue, setEditingValue] = useState('');
const [loading, setLoading] = useState(false);
@@ -97,6 +98,9 @@ function IPRangesManager() {
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState('');
useEffect(() => {
// Acquire soft lock
const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`;
@@ -108,6 +112,7 @@ function IPRangesManager() {
acquire();
const interval = setInterval(acquire, 60_000);
fetchItems();
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
return () => {
clearInterval(interval);
api.delete(`/locks/${resource}`).catch(() => {});
@@ -138,7 +143,11 @@ function IPRangesManager() {
};
useEffect(() => {
fetchItems();
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
fetchItems();
}, 350);
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, searchTerm, filterCommunity]);
@@ -228,7 +237,6 @@ function IPRangesManager() {
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [wsOpen, setWsOpen] = useState(false);
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
// Аналитика
@@ -790,6 +798,7 @@ function IPRangesManager() {
placeholder="Поиск IP-диапазона..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
aria-label="Поиск IP-диапазона"
/>
</div>
</div>
@@ -846,8 +855,8 @@ function IPRangesManager() {
</>
) : (
<>
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
</>
)}
</td>
@@ -966,7 +975,7 @@ function IPRangesManager() {
/>
<WsUpdateModal
show={wsOpen}
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
url={wsUrl}
onClose={() => setWsOpen(false)}
/>
<ConfirmDialog
+28 -1
View File
@@ -9,6 +9,7 @@ export default function SettingsModal({ open, onClose }) {
const [success, setSuccess] = useState('')
const [etag, setEtag] = useState('')
const [dohServer, setDohServer] = useState('')
const [wsUrl, setWsUrl] = useState('')
useEffect(() => {
if (!open) return
@@ -20,6 +21,7 @@ export default function SettingsModal({ open, onClose }) {
const res = await api.get('/ui-settings')
const data = res?.data || {}
setDohServer(String(data?.dohServer || ''))
setWsUrl(String(data?.wsUpdateUrl || ''))
const e = res?.headers?.etag || res?.headers?.ETag || ''
setEtag(e ? String(e) : '')
// фокус на первом поле
@@ -42,13 +44,22 @@ export default function SettingsModal({ open, onClose }) {
} catch { return false }
}
const validateWs = (value) => {
if (!value) return true // допускаем пустое значение
try {
const u = new URL(String(value))
return u.protocol === 'ws:' || u.protocol === 'wss:'
} catch { return false }
}
const onSave = async () => {
setError('')
setSuccess('')
if (!validateDoh(dohServer)) { setError('Укажите корректный HTTPS URL для DoH'); return }
if (!validateWs(wsUrl)) { setError('Укажите корректный WebSocket URL (ws:// или wss://)'); return }
setSaving(true)
try {
const payload = { settings: { dohServer: String(dohServer || '').trim() }, etag }
const payload = { settings: { dohServer: String(dohServer || '').trim(), wsUpdateUrl: String(wsUrl || '').trim() }, etag }
const res = await api.post('/ui-settings', payload)
const meta = res?.data || {}
setSuccess('Настройки сохранены')
@@ -91,6 +102,22 @@ export default function SettingsModal({ open, onClose }) {
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
<div className="mb-3">
<label className="form-label">WebSocket URL (BGP Live)</label>
<input
type="text"
className={`form-control${wsUrl && !validateWs(wsUrl) ? ' is-invalid' : ''}`}
placeholder="ws://host:port/ws/update_bgp?api_key=..."
value={wsUrl}
onChange={(e) => setWsUrl(e.target.value)}
disabled={loading || saving}
/>
<div className="form-hint">URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым.</div>
{wsUrl && !validateWs(wsUrl) && (
<div className="invalid-feedback">Укажите корректный WebSocket URL</div>
)}
</div>
<div className="mb-3">
<label className="form-label">DoH сервер</label>
<input