feat: Add WebSocket and background update functionality to ASNs, Domains, and IPRanges managers; integrate WsUpdateModal for real-time updates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m33s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m33s
This commit is contained in:
@@ -22,6 +22,7 @@ import LockBanner from './components/LockBanner.jsx';
|
|||||||
import TableSkeleton from './components/TableSkeleton.jsx';
|
import TableSkeleton from './components/TableSkeleton.jsx';
|
||||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||||
|
import WsUpdateModal from './components/WsUpdateModal.jsx';
|
||||||
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
||||||
import HistoryModal from './components/HistoryModal.jsx';
|
import HistoryModal from './components/HistoryModal.jsx';
|
||||||
|
|
||||||
@@ -203,6 +204,7 @@ function ASNsNewManager() {
|
|||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [wsOpen, setWsOpen] = 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))
|
||||||
@@ -409,6 +411,19 @@ function ASNsNewManager() {
|
|||||||
onSave={handleSaveChanges}
|
onSave={handleSaveChanges}
|
||||||
disableSave={loading}
|
disableSave={loading}
|
||||||
onHistory={() => setHistoryOpen(true)}
|
onHistory={() => setHistoryOpen(true)}
|
||||||
|
onOnlineUpdate={() => setWsOpen(true)}
|
||||||
|
onBackgroundUpdate={async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://77.232.38.173:8080/api/update_bgp/background?api_key=denozord2502', { method: 'POST' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data.ok === false) {
|
||||||
|
throw new Error(data?.message || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
window.notify?.success('Фоновое обновление запущено', data);
|
||||||
|
} catch (e) {
|
||||||
|
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -671,6 +686,11 @@ function ASNsNewManager() {
|
|||||||
onClose={() => setHistoryOpen(false)}
|
onClose={() => setHistoryOpen(false)}
|
||||||
onRolledBack={() => fetchItems()}
|
onRolledBack={() => fetchItems()}
|
||||||
/>
|
/>
|
||||||
|
<WsUpdateModal
|
||||||
|
show={wsOpen}
|
||||||
|
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
|
||||||
|
onClose={() => setWsOpen(false)}
|
||||||
|
/>
|
||||||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function MetricCard({ title, value, icon: Icon, color, description }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const [globalQuery, setGlobalQuery] = useState('');
|
// Глобальный поиск удалён по требованию UX
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
domainsCount: null,
|
domainsCount: null,
|
||||||
ipRangesCount: null,
|
ipRangesCount: null,
|
||||||
@@ -170,19 +170,7 @@ 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">
|
||||||
@@ -204,9 +192,7 @@ function Dashboard() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={IconWorld}
|
icon={IconWorld}
|
||||||
color="blue"
|
color="blue"
|
||||||
value={loading ? '...' : (globalQuery
|
value={loading ? '...' : (stats.domainsCount ?? '—')}
|
||||||
? 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"
|
||||||
@@ -216,9 +202,7 @@ function Dashboard() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={IconNetwork}
|
icon={IconNetwork}
|
||||||
color="green"
|
color="green"
|
||||||
value={loading ? '...' : (globalQuery
|
value={loading ? '...' : (stats.ipRangesCount ?? '—')}
|
||||||
? 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"
|
||||||
@@ -228,9 +212,7 @@ function Dashboard() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={IconNetwork}
|
icon={IconNetwork}
|
||||||
color="purple"
|
color="purple"
|
||||||
value={loading ? '...' : (globalQuery
|
value={loading ? '...' : (stats.asnsCount ?? '—')}
|
||||||
? 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"
|
||||||
@@ -240,9 +222,7 @@ function Dashboard() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={IconServer}
|
icon={IconServer}
|
||||||
color="orange"
|
color="orange"
|
||||||
value={loading ? '...' : (globalQuery
|
value={loading ? '...' : (stats.serversCount ?? '—')}
|
||||||
? 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"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import LockBanner from './components/LockBanner.jsx';
|
|||||||
import TableSkeleton from './components/TableSkeleton.jsx';
|
import TableSkeleton from './components/TableSkeleton.jsx';
|
||||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||||
|
import WsUpdateModal from './components/WsUpdateModal.jsx';
|
||||||
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
||||||
import HistoryModal from './components/HistoryModal.jsx';
|
import HistoryModal from './components/HistoryModal.jsx';
|
||||||
|
|
||||||
@@ -207,6 +208,7 @@ function DomainsNewManager() {
|
|||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [wsOpen, setWsOpen] = 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))
|
||||||
@@ -416,6 +418,19 @@ function DomainsNewManager() {
|
|||||||
onSave={handleSaveChanges}
|
onSave={handleSaveChanges}
|
||||||
disableSave={loading}
|
disableSave={loading}
|
||||||
onHistory={() => setHistoryOpen(true)}
|
onHistory={() => setHistoryOpen(true)}
|
||||||
|
onOnlineUpdate={() => setWsOpen(true)}
|
||||||
|
onBackgroundUpdate={async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://77.232.38.173:8080/api/update_bgp/background?api_key=denozord2502', { method: 'POST' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data.ok === false) {
|
||||||
|
throw new Error(data?.message || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
window.notify?.success('Фоновое обновление запущено', data);
|
||||||
|
} catch (e) {
|
||||||
|
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -680,6 +695,11 @@ function DomainsNewManager() {
|
|||||||
onClose={() => setHistoryOpen(false)}
|
onClose={() => setHistoryOpen(false)}
|
||||||
onRolledBack={() => fetchItems()}
|
onRolledBack={() => fetchItems()}
|
||||||
/>
|
/>
|
||||||
|
<WsUpdateModal
|
||||||
|
show={wsOpen}
|
||||||
|
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
|
||||||
|
onClose={() => setWsOpen(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">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import LockBanner from './components/LockBanner.jsx';
|
|||||||
import TableSkeleton from './components/TableSkeleton.jsx';
|
import TableSkeleton from './components/TableSkeleton.jsx';
|
||||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||||
|
import WsUpdateModal from './components/WsUpdateModal.jsx';
|
||||||
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
||||||
import HistoryModal from './components/HistoryModal.jsx';
|
import HistoryModal from './components/HistoryModal.jsx';
|
||||||
|
|
||||||
@@ -218,6 +219,7 @@ function IPRangesManager() {
|
|||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [wsOpen, setWsOpen] = 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))
|
||||||
@@ -425,6 +427,19 @@ function IPRangesManager() {
|
|||||||
onSave={handleSaveChanges}
|
onSave={handleSaveChanges}
|
||||||
disableSave={loading}
|
disableSave={loading}
|
||||||
onHistory={() => setHistoryOpen(true)}
|
onHistory={() => setHistoryOpen(true)}
|
||||||
|
onOnlineUpdate={() => setWsOpen(true)}
|
||||||
|
onBackgroundUpdate={async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://77.232.38.173:8080/api/update_bgp/background?api_key=denozord2502', { method: 'POST' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data.ok === false) {
|
||||||
|
throw new Error(data?.message || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
window.notify?.success('Фоновое обновление запущено', data);
|
||||||
|
} catch (e) {
|
||||||
|
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -692,6 +707,11 @@ function IPRangesManager() {
|
|||||||
onClose={() => setHistoryOpen(false)}
|
onClose={() => setHistoryOpen(false)}
|
||||||
onRolledBack={() => fetchItems()}
|
onRolledBack={() => fetchItems()}
|
||||||
/>
|
/>
|
||||||
|
<WsUpdateModal
|
||||||
|
show={wsOpen}
|
||||||
|
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
|
||||||
|
onClose={() => setWsOpen(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">
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconDeviceFloppy
|
IconDeviceFloppy,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconBolt
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
function PageHeaderActions({
|
function PageHeaderActions({
|
||||||
@@ -20,9 +22,21 @@ function PageHeaderActions({
|
|||||||
onSave,
|
onSave,
|
||||||
disableSave = false,
|
disableSave = false,
|
||||||
onHistory,
|
onHistory,
|
||||||
|
onOnlineUpdate,
|
||||||
|
onBackgroundUpdate,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="btn-list">
|
<div className="btn-list">
|
||||||
|
{onOnlineUpdate && (
|
||||||
|
<button className="btn btn-outline-warning" onClick={onOnlineUpdate} title="Online-обновление BGP (WebSocket)">
|
||||||
|
<IconBolt className="me-1" /> Online-обновление
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onBackgroundUpdate && (
|
||||||
|
<button className="btn btn-outline-success" onClick={onBackgroundUpdate} title="Фоновое обновление BGP (POST)">
|
||||||
|
<IconPlayerPlay className="me-1" /> Фоновое обновление
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onPreview && (
|
{onPreview && (
|
||||||
<button className="btn btn-outline-secondary" onClick={onPreview} disabled={disablePreview} title="Предпросмотр изменений">
|
<button className="btn btn-outline-secondary" onClick={onPreview} disabled={disablePreview} title="Предпросмотр изменений">
|
||||||
<IconEye className="me-1" /> Предпросмотр
|
<IconEye className="me-1" /> Предпросмотр
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { IconX, IconPlayerPlay, IconPlugConnected } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
function WsUpdateModal({ show, url, onClose }) {
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [status, setStatus] = useState('connecting'); // connecting | open | closed | error
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const bottomRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) return;
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
|
setStatus('connecting');
|
||||||
|
ws.onopen = () => setStatus('open');
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
const text = typeof evt.data === 'string' ? evt.data : '[binary]';
|
||||||
|
setMessages((prev) => [...prev, text]);
|
||||||
|
};
|
||||||
|
ws.onerror = () => setStatus('error');
|
||||||
|
ws.onclose = () => setStatus('closed');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
try { wsRef.current?.close(); } catch {}
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [show, url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, show]);
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-lg">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title d-flex align-items-center">
|
||||||
|
<IconPlugConnected className="me-2" /> Online-обновление BGP
|
||||||
|
<span className={`badge ms-2 ${status === 'open' ? 'bg-green-lt text-green' : status === 'connecting' ? 'bg-orange-lt text-orange' : status === 'error' ? 'bg-red-lt text-red' : 'bg-secondary-lt text-secondary'}`}>{status}</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" className="btn btn-close" onClick={onClose}><IconX size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body" style={{ maxHeight: 360, overflowY: 'auto', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', fontSize: '0.875rem' }}>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="text-muted">Ожидание сообщений от сервера...</div>
|
||||||
|
) : (
|
||||||
|
messages.map((m, i) => (
|
||||||
|
<div key={i} className="text-break"><span className="text-muted">$</span> {m}</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}><IconX className="me-1" /> Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WsUpdateModal;
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user