feat: Implement date formatting utility functions and update components to use formatted timestamps for improved readability and consistency across the application.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
|
import { formatDateTime } from './lib/datetime.js';
|
||||||
import {
|
import {
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconNetwork,
|
IconNetwork,
|
||||||
@@ -130,7 +131,7 @@ function Dashboard() {
|
|||||||
// Получаем дату последнего обновления
|
// Получаем дату последнего обновления
|
||||||
// Новый формат: объект с ключами { domainsNew, asns, servers, filters, ipRanges }
|
// Новый формат: объект с ключами { domainsNew, asns, servers, filters, ipRanges }
|
||||||
const lmRaw = s3Res.status === 'fulfilled' ? s3Res.value.data?.domainsNew?.lastModified : null;
|
const lmRaw = s3Res.status === 'fulfilled' ? s3Res.value.data?.domainsNew?.lastModified : null;
|
||||||
const lastModified = lmRaw ? new Date(lmRaw).toLocaleString() : new Date().toLocaleString();
|
const lastModified = lmRaw ? formatDateTime(lmRaw) : formatDateTime(new Date());
|
||||||
|
|
||||||
const domainsCount = domainsRes.status === 'fulfilled' && typeof domainsRes.value.data?.total === 'number'
|
const domainsCount = domainsRes.status === 'fulfilled' && typeof domainsRes.value.data?.total === 'number'
|
||||||
? domainsRes.value.data.total
|
? domainsRes.value.data.total
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ import {
|
|||||||
IconFileText
|
IconFileText
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
|
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
|
||||||
|
import {
|
||||||
|
AddFilterModal,
|
||||||
|
EditFilterModal,
|
||||||
|
DeleteFilterModal,
|
||||||
|
PreviewConfigModal,
|
||||||
|
AddFilterServerModal
|
||||||
|
} from './components/filter/index.js';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -2249,366 +2256,4 @@ function FilterManager() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модальное окно добавления фильтра
|
|
||||||
function AddFilterModal({ show, newFilter, onNewFilterChange, onAddFilter, onClose, error, communities = [] }) {
|
|
||||||
if (!show) return null;
|
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
|
||||||
onNewFilterChange({ ...newFilter, [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onAddFilter();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
||||||
<div className="modal-dialog">
|
|
||||||
<div className="modal-content">
|
|
||||||
<div className="modal-header">
|
|
||||||
<h5 className="modal-title">Добавить новый фильтр</h5>
|
|
||||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="modal-body">
|
|
||||||
{error && (
|
|
||||||
<div className="alert alert-danger">
|
|
||||||
<IconAlertTriangle className="me-2" />
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Community *</label>
|
|
||||||
<input
|
|
||||||
list="community-options"
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Начните вводить или выберите из списка"
|
|
||||||
value={newFilter.community}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
onNewFilterChange({ ...newFilter, community: v });
|
|
||||||
const found = communities.find(c => c.value === v);
|
|
||||||
if (found && found.gatewayDefault && !newFilter.gateway) {
|
|
||||||
onNewFilterChange({ ...newFilter, community: v, gateway: found.gatewayDefault });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<datalist id="community-options">
|
|
||||||
{communities.map(c => (
|
|
||||||
<option key={c.value} value={c.value}>{c.name ? `${c.name} — ${c.value}` : c.value}</option>
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
<div className="form-text">Поддерживается формат AS:NNN. Вы можете выбрать готовое значение из справочника.</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Gateway *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="SWE-HIPHOST"
|
|
||||||
value={newFilter.gateway}
|
|
||||||
onChange={(e) => handleChange('gateway', e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<div className="form-text">Название gateway для маршрутизации</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Описание</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Описание фильтра"
|
|
||||||
value={newFilter.description}
|
|
||||||
onChange={(e) => handleChange('description', e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="form-text">Необязательное описание фильтра</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
<IconPlus className="me-2" />
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модальное окно редактирования фильтра
|
|
||||||
function EditFilterModal({ show, filter, onChange, onSave, onClose }) {
|
|
||||||
if (!show || !filter) return null;
|
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
|
||||||
onChange({ ...filter, [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave(filter);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
||||||
<div className="modal-dialog">
|
|
||||||
<div className="modal-content">
|
|
||||||
<div className="modal-header">
|
|
||||||
<h5 className="modal-title">Редактировать фильтр</h5>
|
|
||||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="modal-body">
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Community *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="65001:200"
|
|
||||||
value={filter?.community || ''}
|
|
||||||
onChange={(e) => handleChange('community', e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Gateway *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="SWE-HIPHOST"
|
|
||||||
value={filter?.gateway || ''}
|
|
||||||
onChange={(e) => handleChange('gateway', e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Описание</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Описание фильтра"
|
|
||||||
value={filter?.description || ''}
|
|
||||||
onChange={(e) => handleChange('description', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
<IconCheck className="me-2" />
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модальное окно удаления фильтра
|
|
||||||
function DeleteFilterModal({ show, filter, onDelete, onClose }) {
|
|
||||||
if (!show || !filter) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
||||||
<div className="modal-dialog">
|
|
||||||
<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">
|
|
||||||
<p>Вы уверены, что хотите удалить фильтр?</p>
|
|
||||||
<div className="alert alert-warning">
|
|
||||||
<strong>Community:</strong> {filter?.community || ''}<br />
|
|
||||||
<strong>Gateway:</strong> {filter?.gateway || ''}<br />
|
|
||||||
{filter?.description && <><strong>Описание:</strong> {filter.description}</>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-danger" onClick={onDelete}>
|
|
||||||
<IconTrash className="me-2" />
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модальное окно предварительного просмотра конфигурации
|
|
||||||
function PreviewConfigModal({ show, config, mode, onClose, onCopy }) {
|
|
||||||
if (!show) return null;
|
|
||||||
|
|
||||||
const getModalTitle = () => {
|
|
||||||
if (mode === 'simple') {
|
|
||||||
return 'Предварительный просмотр конфигурации MikroTik (упрощённый режим)';
|
|
||||||
} else if (mode === 'advanced') {
|
|
||||||
return 'Предварительный просмотр конфигурации MikroTik (расширенный режим)';
|
|
||||||
}
|
|
||||||
return 'Предварительный просмотр конфигурации MikroTik';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getModeBadge = () => {
|
|
||||||
if (mode === 'simple') {
|
|
||||||
return <span className="badge bg-blue-lt text-blue ms-2">Упрощённый режим</span>;
|
|
||||||
} else if (mode === 'advanced') {
|
|
||||||
return <span className="badge bg-green-lt text-green ms-2">Расширенный режим</span>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConfigInfo = () => {
|
|
||||||
if (config.includes('// Сначала выберите сервер') || config.includes('// Нет фильтров для генерации конфигурации')) {
|
|
||||||
return <div className="alert alert-warning mb-3">Нет данных для генерации конфигурации</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подсчитываем количество фильтров из конфигурации
|
|
||||||
const communityMatches = config.match(/bgp-communities includes [^)]+/g);
|
|
||||||
const filterCount = communityMatches ? communityMatches.length : 0;
|
|
||||||
|
|
||||||
if (filterCount > 0) {
|
|
||||||
return (
|
|
||||||
<div className="alert alert-info mb-3">
|
|
||||||
<IconFilter className="me-2" />
|
|
||||||
Конфигурация сгенерирована на основе {filterCount} фильтр(ов)
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal show d-block" 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">
|
|
||||||
{getModalTitle()}
|
|
||||||
{getModeBadge()}
|
|
||||||
</h5>
|
|
||||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
{getConfigInfo()}
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="btn-list">
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={onCopy}
|
|
||||||
disabled={config.includes('// Сначала выберите сервер') || config.includes('// Нет фильтров для генерации конфигурации')}
|
|
||||||
>
|
|
||||||
<IconCopy className="me-2" />
|
|
||||||
Копировать в буфер обмена
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<pre className="bg-dark text-light p-3 rounded" style={{ maxHeight: '400px', overflow: 'auto' }}>
|
|
||||||
<code>{config}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
||||||
Закрыть
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модальное окно добавления сервера
|
|
||||||
function AddServerModal({ show, newServer, onNewServerChange, onAddServer, onClose, error }) {
|
|
||||||
if (!show) return null;
|
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
|
||||||
onNewServerChange({ ...newServer, [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onAddServer();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
||||||
<div className="modal-dialog">
|
|
||||||
<div className="modal-content">
|
|
||||||
<div className="modal-header">
|
|
||||||
<h5 className="modal-title">Добавить новый сервер</h5>
|
|
||||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="modal-body">
|
|
||||||
{error && (
|
|
||||||
<div className="alert alert-danger">
|
|
||||||
<IconAlertTriangle className="me-2" />
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Имя сервера *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Например, SWE-HIPHOST"
|
|
||||||
value={newServer.name}
|
|
||||||
onChange={(e) => handleChange('name', e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Описание</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Необязательное описание сервера"
|
|
||||||
value={newServer.description}
|
|
||||||
onChange={(e) => handleChange('description', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Включен</label>
|
|
||||||
<div className="form-check">
|
|
||||||
<input
|
|
||||||
className="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
checked={newServer.enabled}
|
|
||||||
onChange={(e) => handleChange('enabled', e.target.checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
<IconPlus className="me-2" />
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FilterManager;
|
export default FilterManager;
|
||||||
@@ -31,6 +31,12 @@ import {
|
|||||||
needsGateways as NEEDS_GATEWAYS,
|
needsGateways as NEEDS_GATEWAYS,
|
||||||
SERVER_TYPE_OPTIONS
|
SERVER_TYPE_OPTIONS
|
||||||
} from './utils/serverUtils.js';
|
} from './utils/serverUtils.js';
|
||||||
|
import {
|
||||||
|
EditServerModal,
|
||||||
|
DeleteServerModal,
|
||||||
|
LinkGeneratorModal,
|
||||||
|
AddServerModal
|
||||||
|
} from './components/server/index.js';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -1407,677 +1413,4 @@ function ServerManager() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модальное окно для редактирования сервера с backdrop и анимацией
|
|
||||||
function EditServerModal({ show, server, onChange, onSave, onClose }) {
|
|
||||||
const modalRef = useRef(null);
|
|
||||||
const initializedRef = useRef(false);
|
|
||||||
// Локальное состояние для формы
|
|
||||||
const [localServer, setLocalServer] = useState({});
|
|
||||||
|
|
||||||
// Функция для создания сервера с гарантированными gateways
|
|
||||||
const ensureGateways = (srv) => {
|
|
||||||
if (!srv) return { gateways: [] };
|
|
||||||
const needs = NEEDS_GATEWAYS(srv.type);
|
|
||||||
let gateways = [];
|
|
||||||
if (needs) {
|
|
||||||
gateways = normalizeGateways(srv.gateways, srv.gateway || srv.dns || srv.ip);
|
|
||||||
if (gateways.length === 0) {
|
|
||||||
gateways = [makeGateway({ primary: true })];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...srv, gateways };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Синхронизируем локальное состояние с пропсами ТОЛЬКО при открытии модалки
|
|
||||||
useEffect(() => {
|
|
||||||
if (show && server && !initializedRef.current) {
|
|
||||||
const prepared = ensureGateways(server);
|
|
||||||
console.log('[EditServerModal] Инициализация с сервером:', prepared);
|
|
||||||
setLocalServer(prepared);
|
|
||||||
initializedRef.current = true;
|
|
||||||
}
|
|
||||||
// Сбрасываем флаг при закрытии
|
|
||||||
if (!show) {
|
|
||||||
initializedRef.current = false;
|
|
||||||
}
|
|
||||||
}, [show, server]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
|
||||||
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
|
||||||
if (show) {
|
|
||||||
modalInstance.show();
|
|
||||||
} else {
|
|
||||||
modalInstance.hide();
|
|
||||||
}
|
|
||||||
// Закрытие по событию Tabler
|
|
||||||
const handler = () => onClose && onClose();
|
|
||||||
modalRef.current.addEventListener('hide.bs.modal', handler);
|
|
||||||
return () => {
|
|
||||||
if (modalRef.current) {
|
|
||||||
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [show, onClose]);
|
|
||||||
|
|
||||||
// Универсальная функция обновления поля
|
|
||||||
const handleFieldChange = (field, value) => {
|
|
||||||
setLocalServer(prev => {
|
|
||||||
const updated = { ...prev, [field]: value };
|
|
||||||
onChange && onChange(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функция обновления шлюза по индексу
|
|
||||||
const handleGatewayChange = (idx, field, value) => {
|
|
||||||
setLocalServer(prev => {
|
|
||||||
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
|
||||||
const updatedGateways = currentGateways.map((gw, i) =>
|
|
||||||
i === idx ? { ...gw, [field]: value } : gw
|
|
||||||
);
|
|
||||||
const updated = { ...prev, gateways: updatedGateways };
|
|
||||||
onChange && onChange(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функция переключения primary шлюза
|
|
||||||
const handlePrimaryChange = (idx, checked) => {
|
|
||||||
setLocalServer(prev => {
|
|
||||||
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
|
||||||
const updatedGateways = currentGateways.map((gw, i) => ({
|
|
||||||
...gw,
|
|
||||||
primary: i === idx ? checked : false
|
|
||||||
}));
|
|
||||||
// Убедимся, что хотя бы один primary
|
|
||||||
if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) {
|
|
||||||
updatedGateways[idx].primary = true;
|
|
||||||
}
|
|
||||||
const updated = { ...prev, gateways: updatedGateways };
|
|
||||||
onChange && onChange(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функция добавления нового шлюза
|
|
||||||
const handleAddGateway = () => {
|
|
||||||
console.log('[EditServerModal] handleAddGateway вызвана, текущий localServer:', localServer);
|
|
||||||
setLocalServer(prev => {
|
|
||||||
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
|
||||||
const newGw = makeGateway({ primary: currentGateways.length === 0 });
|
|
||||||
const updatedGateways = [...currentGateways, newGw];
|
|
||||||
const updated = { ...prev, gateways: updatedGateways };
|
|
||||||
console.log('[EditServerModal] Новый localServer после добавления шлюза:', updated);
|
|
||||||
onChange && onChange(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функция удаления шлюза
|
|
||||||
const handleRemoveGateway = (idx) => {
|
|
||||||
setLocalServer(prev => {
|
|
||||||
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
|
||||||
if (currentGateways.length <= 1) return prev; // Не удаляем последний
|
|
||||||
const updatedGateways = currentGateways.filter((_, i) => i !== idx);
|
|
||||||
// Убедимся, что есть primary
|
|
||||||
if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) {
|
|
||||||
updatedGateways[0].primary = true;
|
|
||||||
}
|
|
||||||
const updated = { ...prev, gateways: updatedGateways };
|
|
||||||
onChange && onChange(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Получаем текущие шлюзы для рендера
|
|
||||||
const currentGateways = Array.isArray(localServer.gateways) ? localServer.gateways : [];
|
|
||||||
const showGateways = NEEDS_GATEWAYS(localServer.type);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal" tabIndex="-1" ref={modalRef}>
|
|
||||||
<div className="modal-dialog">
|
|
||||||
<div className="modal-content">
|
|
||||||
<div className="modal-header">
|
|
||||||
<h5 className="modal-title">Редактировать сервер</h5>
|
|
||||||
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
<form onSubmit={e => { e.preventDefault(); onSave && onSave(localServer); }}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">ID (опционально)</label>
|
|
||||||
<input type="text" className="form-control" value={localServer.id || ''} onChange={e => handleFieldChange('id', e.target.value)} placeholder="srv-1" />
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">IP адрес</label>
|
|
||||||
<input type="text" className="form-control" value={localServer.ip || ''} onChange={e => handleFieldChange('ip', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Внешний IP (опционально)</label>
|
|
||||||
<input type="text" className="form-control" value={localServer.extIp || ''} onChange={e => handleFieldChange('extIp', e.target.value)} placeholder="публичный IP, если отличается" />
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Внутренний IP (опционально)</label>
|
|
||||||
<input type="text" className="form-control" value={localServer.internalIp || ''} onChange={e => handleFieldChange('internalIp', e.target.value)} placeholder="10.x.x.x / 192.168.x.x" />
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">DNS имя</label>
|
|
||||||
<input type="text" className="form-control" value={localServer.dns || ''} onChange={e => handleFieldChange('dns', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Страна</label>
|
|
||||||
<input type="text" className="form-control" value={localServer.country || ''} onChange={e => handleFieldChange('country', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Провайдер</label>
|
|
||||||
<input type="text" className="form-control" value={localServer.provider || ''} onChange={e => handleFieldChange('provider', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Тип сервера</label>
|
|
||||||
<select className="form-select" value={localServer.type || 'jumphost'} onChange={e => handleFieldChange('type', e.target.value)}>
|
|
||||||
{SERVER_TYPE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Тип туннеля</label>
|
|
||||||
<input type="text" className="form-control" value={localServer.tunnel || ''} onChange={e => handleFieldChange('tunnel', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
{showGateways && (
|
|
||||||
<>
|
|
||||||
<div className="form-label">Шлюзы ({currentGateways.length})</div>
|
|
||||||
<div className="list-group list-group-flush border rounded mb-2">
|
|
||||||
{currentGateways.map((gw, idx) => (
|
|
||||||
<div key={gw.id || `gw-${idx}`} className="list-group-item">
|
|
||||||
<div className="row g-2 align-items-end">
|
|
||||||
<div className="col-12 col-md-4">
|
|
||||||
<label className="form-label small mb-1">Имя *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={gw.name || ''}
|
|
||||||
onChange={(e) => handleGatewayChange(idx, 'name', e.target.value)}
|
|
||||||
placeholder="GW-MSK"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-4">
|
|
||||||
<label className="form-label small mb-1">IP</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={gw.ip || ''}
|
|
||||||
onChange={(e) => handleGatewayChange(idx, 'ip', e.target.value)}
|
|
||||||
placeholder="10.0.0.1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-3">
|
|
||||||
<label className="form-label small mb-1">Комментарий</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={gw.comment || ''}
|
|
||||||
onChange={(e) => handleGatewayChange(idx, 'comment', e.target.value)}
|
|
||||||
placeholder="Основной шлюз"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-1 d-flex align-items-center justify-content-center">
|
|
||||||
<div className="form-check form-switch m-0">
|
|
||||||
<input
|
|
||||||
className="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!gw.primary}
|
|
||||||
onChange={(e) => handlePrimaryChange(idx, e.target.checked)}
|
|
||||||
title="Основной шлюз"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="d-flex justify-content-between align-items-center mt-2">
|
|
||||||
<div className="text-muted small">{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}</div>
|
|
||||||
{currentGateways.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-link text-danger px-0"
|
|
||||||
onClick={() => handleRemoveGateway(idx)}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-outline-primary btn-sm"
|
|
||||||
onClick={handleAddGateway}
|
|
||||||
>
|
|
||||||
<IconPlus className="icon" />
|
|
||||||
Добавить шлюз
|
|
||||||
</button>
|
|
||||||
<div className="form-text">Для jumphost/exit должен быть один основной gateway.</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
|
|
||||||
<span className="fw-bold">Отмена</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={() => onSave && onSave(localServer)}>
|
|
||||||
<span className="fw-bold">Сохранить</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeleteServerModal({ show, server, onDelete, onClose }) {
|
|
||||||
const modalRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
|
||||||
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
|
||||||
if (show) {
|
|
||||||
modalInstance.show();
|
|
||||||
} else {
|
|
||||||
modalInstance.hide();
|
|
||||||
}
|
|
||||||
const handler = () => onClose && onClose();
|
|
||||||
modalRef.current.addEventListener('hide.bs.modal', handler);
|
|
||||||
return () => {
|
|
||||||
if (modalRef.current) {
|
|
||||||
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [show, onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal" tabIndex="-1" ref={modalRef}>
|
|
||||||
<div className="modal-dialog">
|
|
||||||
<div className="modal-content">
|
|
||||||
<div className="modal-status bg-danger"></div>
|
|
||||||
<div className="modal-header">
|
|
||||||
<IconAlertTriangle className="icon text-danger me-2" size={28} />
|
|
||||||
<h5 className="modal-title">Подтверждение удаления</h5>
|
|
||||||
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
<p>Вы уверены, что хотите удалить сервер <strong>{server?.ip}</strong>?</p>
|
|
||||||
<p className="text-muted">Это действие нельзя отменить.</p>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
|
|
||||||
<span className="fw-bold">Отмена</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-danger d-flex align-items-center" onClick={onDelete}>
|
|
||||||
<span className="fw-bold">Удалить</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модальное окно для генератора ссылок
|
|
||||||
function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, onGenerateUrl, onCopyToClipboard, onClose }) {
|
|
||||||
const modalRef = useRef(null);
|
|
||||||
const [localUrlSettings, setLocalUrlSettings] = useState(urlSettings);
|
|
||||||
|
|
||||||
// Синхронизируем локальное состояние с пропсами
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalUrlSettings(urlSettings);
|
|
||||||
}, [urlSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
|
||||||
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
|
||||||
if (show) {
|
|
||||||
modalInstance.show();
|
|
||||||
} else {
|
|
||||||
modalInstance.hide();
|
|
||||||
}
|
|
||||||
const handler = () => onClose && onClose();
|
|
||||||
modalRef.current.addEventListener('hide.bs.modal', handler);
|
|
||||||
return () => {
|
|
||||||
if (modalRef.current) {
|
|
||||||
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [show, onClose]);
|
|
||||||
|
|
||||||
const handleSettingChange = (key, value) => {
|
|
||||||
const updated = { ...localUrlSettings, [key]: value };
|
|
||||||
setLocalUrlSettings(updated);
|
|
||||||
onUrlSettingsChange(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveSettings = () => {
|
|
||||||
onUrlSettingsChange(localUrlSettings);
|
|
||||||
// Сохраняем в localStorage
|
|
||||||
localStorage.setItem('urlSettings', JSON.stringify(localUrlSettings));
|
|
||||||
};
|
|
||||||
|
|
||||||
const generatedUrl = server ? onGenerateUrl(server) : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal modal-lg" tabIndex="-1" ref={modalRef}>
|
|
||||||
<div className="modal-dialog">
|
|
||||||
<div className="modal-content">
|
|
||||||
<div className="modal-header">
|
|
||||||
<h5 className="modal-title">
|
|
||||||
<IconLink className="icon me-2" />
|
|
||||||
Генератор ссылок для {server?.ip}
|
|
||||||
</h5>
|
|
||||||
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-md-6">
|
|
||||||
<h6 className="mb-3">Настройки URL</h6>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Базовый URL</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={localUrlSettings.baseUrl}
|
|
||||||
onChange={e => handleSettingChange('baseUrl', e.target.value)}
|
|
||||||
placeholder="https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Cloudflare Gateway</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={localUrlSettings.cloudflare_gateway}
|
|
||||||
onChange={e => handleSettingChange('cloudflare_gateway', e.target.value)}
|
|
||||||
placeholder="SWE-IHOR или IP"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Bunny Gateway</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={localUrlSettings.bunny_gateway}
|
|
||||||
onChange={e => handleSettingChange('bunny_gateway', e.target.value)}
|
|
||||||
placeholder="SWE-IHOR или IP"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Fastly Gateway</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={localUrlSettings.fastly_gateway}
|
|
||||||
onChange={e => handleSettingChange('fastly_gateway', e.target.value)}
|
|
||||||
placeholder="SWE-IHOR или IP"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Telegram Gateway</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={localUrlSettings.telegram_gateway}
|
|
||||||
onChange={e => handleSettingChange('telegram_gateway', e.target.value)}
|
|
||||||
placeholder="IP адрес"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Hetzner Gateway</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={localUrlSettings.hetzner_gateway}
|
|
||||||
onChange={e => handleSettingChange('hetzner_gateway', e.target.value)}
|
|
||||||
placeholder="IP адрес"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Тип</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={localUrlSettings.type}
|
|
||||||
onChange={e => handleSettingChange('type', e.target.value)}
|
|
||||||
placeholder="routes"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Версия</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={localUrlSettings.version}
|
|
||||||
onChange={e => handleSettingChange('version', e.target.value)}
|
|
||||||
placeholder="v4.rsc"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<h6 className="mb-3">Сгенерированная ссылка</h6>
|
|
||||||
<div className="input-group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={generatedUrl}
|
|
||||||
readOnly
|
|
||||||
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-secondary d-flex align-items-center"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onCopyToClipboard(generatedUrl)}
|
|
||||||
title="Копировать в буфер обмена"
|
|
||||||
>
|
|
||||||
<span className="me-2 d-flex align-items-center"><IconDownload size={18} /></span>
|
|
||||||
<span className="fw-bold">Копировать</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
|
|
||||||
<span className="fw-bold">Закрыть</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={handleSaveSettings}>
|
|
||||||
<span className="fw-bold">Сохранить настройки</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модальное окно для добавления нового сервера
|
|
||||||
function AddServerModal({ show, newServer, customProvider, onNewServerChange, onCustomProviderChange, onAddServer, onClose, error }) {
|
|
||||||
const modalRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
|
||||||
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
|
||||||
if (show) {
|
|
||||||
modalInstance.show();
|
|
||||||
} else {
|
|
||||||
modalInstance.hide();
|
|
||||||
}
|
|
||||||
const handler = () => onClose && onClose();
|
|
||||||
modalRef.current.addEventListener('hide.bs.modal', handler);
|
|
||||||
return () => {
|
|
||||||
if (modalRef.current) {
|
|
||||||
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [show, onClose]);
|
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
|
||||||
onNewServerChange({ ...newServer, [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onAddServer();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal" tabIndex="-1" ref={modalRef}>
|
|
||||||
<div className="modal-dialog">
|
|
||||||
<div className="modal-content">
|
|
||||||
<div className="modal-header">
|
|
||||||
<h5 className="modal-title">
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить новый сервер
|
|
||||||
</h5>
|
|
||||||
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
{error && (
|
|
||||||
<div className="alert alert-danger" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">IP адрес *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
value={newServer.ip}
|
|
||||||
onChange={(e) => handleChange('ip', e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">DNS имя *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="srv1.shx.su"
|
|
||||||
value={newServer.dns}
|
|
||||||
onChange={(e) => handleChange('dns', e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Страна *</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={newServer.country}
|
|
||||||
onChange={(e) => handleChange('country', e.target.value)}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Выберите страну</option>
|
|
||||||
<option value="RU">Россия (RU)</option>
|
|
||||||
<option value="SWE">Швеция (SWE)</option>
|
|
||||||
<option value="US">США (US)</option>
|
|
||||||
<option value="DE">Германия (DE)</option>
|
|
||||||
<option value="NL">Нидерланды (NL)</option>
|
|
||||||
<option value="SG">Сингапур (SG)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Провайдер *</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={newServer.provider}
|
|
||||||
onChange={(e) => handleChange('provider', e.target.value)}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Выберите провайдера</option>
|
|
||||||
<option value="Yandex">Yandex Cloud</option>
|
|
||||||
<option value="AWS">Amazon Web Services</option>
|
|
||||||
<option value="Google">Google Cloud</option>
|
|
||||||
<option value="Azure">Microsoft Azure</option>
|
|
||||||
<option value="Hetzner">Hetzner</option>
|
|
||||||
<option value="OVH">OVH</option>
|
|
||||||
<option value="DigitalOcean">DigitalOcean</option>
|
|
||||||
<option value="Vultr">Vultr</option>
|
|
||||||
<option value="Linode">Linode</option>
|
|
||||||
<option value="VPSVILLE">VPSVILLE</option>
|
|
||||||
<option value="HIPHOST">HIPHOST</option>
|
|
||||||
<option value="Другой">Другой</option>
|
|
||||||
</select>
|
|
||||||
{newServer.provider === 'Другой' && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control mt-2"
|
|
||||||
placeholder="Введите название провайдера"
|
|
||||||
value={customProvider}
|
|
||||||
onChange={(e) => onCustomProviderChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Тип сервера *</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={newServer.type}
|
|
||||||
onChange={(e) => handleChange('type', e.target.value)}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Выберите тип</option>
|
|
||||||
{SERVER_TYPE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Тип туннеля *</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={newServer.tunnel}
|
|
||||||
onChange={(e) => handleChange('tunnel', e.target.value)}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="GRE">GRE (Generic Routing Encapsulation)</option>
|
|
||||||
<option value="IPSec">IPSec</option>
|
|
||||||
<option value="WireGuard">WireGuard</option>
|
|
||||||
<option value="OpenVPN">OpenVPN</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Основной шлюз *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Cloudflare, Hetzner, Мой шлюз и т.д."
|
|
||||||
value={newServer.gateway}
|
|
||||||
onChange={(e) => handleChange('gateway', e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
|
|
||||||
<span className="fw-bold">Отмена</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={onAddServer}>
|
|
||||||
<span className="me-2 d-flex align-items-center"><IconPlus className="icon" /></span>
|
|
||||||
<span className="fw-bold">Добавить сервер</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ServerManager;
|
export default ServerManager;
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
|
import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
|
||||||
|
import EmptyState from './EmptyState.jsx';
|
||||||
|
import Pagination from './Pagination.jsx';
|
||||||
|
import Tooltip from './Tooltip.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальный компонент таблицы данных
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {Array} props.columns - Массив описаний колонок
|
||||||
|
* - { key: string, title: string, sortable?: boolean, icon?: Component, render?: (value, item) => ReactNode }
|
||||||
|
* @param {Array} props.items - Массив данных для отображения
|
||||||
|
* @param {string} props.itemKey - Ключ уникального идентификатора элемента
|
||||||
|
* @param {boolean} props.loading - Состояние загрузки
|
||||||
|
* @param {string} props.sortField - Текущее поле сортировки
|
||||||
|
* @param {string} props.sortOrder - Направление сортировки ('asc' | 'desc')
|
||||||
|
* @param {function} props.onSort - Обработчик изменения сортировки
|
||||||
|
* @param {Set} props.selectedItems - Набор выбранных элементов
|
||||||
|
* @param {function} props.onSelectItem - Обработчик выбора элемента
|
||||||
|
* @param {function} props.onSelectAll - Обработчик выбора всех
|
||||||
|
* @param {function} props.onDeselectAll - Обработчик снятия выбора
|
||||||
|
* @param {function} props.onEdit - Обработчик редактирования (item) => void
|
||||||
|
* @param {function} props.onDelete - Обработчик удаления (item) => void
|
||||||
|
* @param {function} props.onCopy - Обработчик копирования (item) => void
|
||||||
|
* @param {Object} props.inlineEdit - Настройки инлайн-редактирования
|
||||||
|
* - { editingKey: string, editingValue: string, onSave: () => void, onCancel: () => void, onChange: (value) => void, renderEditor?: () => ReactNode }
|
||||||
|
* @param {Object} props.pagination - Настройки пагинации
|
||||||
|
* - { currentPage, totalPages, totalItems, pageSize, onPageChange }
|
||||||
|
* @param {Object} props.emptyState - Настройки пустого состояния
|
||||||
|
* - { title, description, action, secondaryAction }
|
||||||
|
* @param {Array} props.actions - Дополнительные действия в строке
|
||||||
|
* - [{ icon: Component, label: string, onClick: (item) => void, variant?: string }]
|
||||||
|
* @param {boolean} props.selectable - Включить чекбоксы выбора (default: true)
|
||||||
|
* @param {number} props.skeletonRows - Количество строк скелетона (default: 10)
|
||||||
|
*/
|
||||||
|
function DataTable({
|
||||||
|
columns = [],
|
||||||
|
items = [],
|
||||||
|
itemKey = 'id',
|
||||||
|
loading = false,
|
||||||
|
sortField,
|
||||||
|
sortOrder = 'asc',
|
||||||
|
onSort,
|
||||||
|
selectedItems = new Set(),
|
||||||
|
onSelectItem,
|
||||||
|
onSelectAll,
|
||||||
|
onDeselectAll,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCopy,
|
||||||
|
inlineEdit,
|
||||||
|
pagination,
|
||||||
|
emptyState,
|
||||||
|
actions = [],
|
||||||
|
selectable = true,
|
||||||
|
skeletonRows = 10,
|
||||||
|
}) {
|
||||||
|
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
|
||||||
|
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey;
|
||||||
|
|
||||||
|
// Рендер заголовка колонки
|
||||||
|
const renderColumnHeader = (col) => {
|
||||||
|
const isSortable = col.sortable !== false && onSort;
|
||||||
|
const isActive = sortField === col.key;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{col.icon && <col.icon size={16} className="me-1 text-muted" />}
|
||||||
|
{col.title}
|
||||||
|
{isSortable && isActive && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSortable) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className="cursor-pointer user-select-none"
|
||||||
|
onClick={() => onSort(col.key)}
|
||||||
|
style={col.width ? { width: col.width } : undefined}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th key={col.key} style={col.width ? { width: col.width } : undefined}>
|
||||||
|
{content}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Рендер ячейки
|
||||||
|
const renderCell = (col, item) => {
|
||||||
|
const value = item[col.key];
|
||||||
|
|
||||||
|
if (col.render) {
|
||||||
|
return col.render(value, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Рендер действий
|
||||||
|
const renderActions = (item) => {
|
||||||
|
if (isEditing(item)) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{inlineEdit.renderEditor && inlineEdit.renderEditor(item)}
|
||||||
|
<Tooltip content="Сохранить (Enter)">
|
||||||
|
<button
|
||||||
|
className="btn btn-success btn-icon btn-sm me-1"
|
||||||
|
onClick={inlineEdit.onSave}
|
||||||
|
aria-label="Сохранить"
|
||||||
|
>
|
||||||
|
<IconCheck size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Отмена (Esc)">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-icon btn-sm"
|
||||||
|
onClick={inlineEdit.onCancel}
|
||||||
|
aria-label="Отмена"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{onEdit && (
|
||||||
|
<Tooltip content="Редактировать" position="top">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary btn-icon btn-sm me-1"
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
aria-label="Редактировать"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{onCopy && (
|
||||||
|
<Tooltip content="Копировать" position="top">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-icon btn-sm me-1"
|
||||||
|
onClick={() => onCopy(item)}
|
||||||
|
aria-label="Копировать"
|
||||||
|
>
|
||||||
|
<IconCopy size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{actions.map((action, idx) => (
|
||||||
|
<Tooltip key={idx} content={action.label} position="top">
|
||||||
|
<button
|
||||||
|
className={`btn btn-outline-${action.variant || 'secondary'} btn-icon btn-sm me-1`}
|
||||||
|
onClick={() => action.onClick(item)}
|
||||||
|
aria-label={action.label}
|
||||||
|
>
|
||||||
|
<action.icon size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
{onDelete && (
|
||||||
|
<Tooltip content="Удалить" position="top">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger btn-icon btn-sm"
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
aria-label="Удалить"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загрузка
|
||||||
|
if (loading) {
|
||||||
|
return <TableSkeleton rows={skeletonRows} cols={columns.length + (hasActions ? 1 : 0)} hasCheckbox={selectable} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустое состояние
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<TableEmpty cols={columns.length + (hasActions ? 1 : 0) + (selectable ? 1 : 0)}>
|
||||||
|
{emptyState ? (
|
||||||
|
<EmptyState
|
||||||
|
title={emptyState.title}
|
||||||
|
description={emptyState.description}
|
||||||
|
action={emptyState.action}
|
||||||
|
secondaryAction={emptyState.secondaryAction}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Нет данных" description="Добавьте записи, чтобы начать." />
|
||||||
|
)}
|
||||||
|
</TableEmpty>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{selectable && (
|
||||||
|
<th style={{ width: '40px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={(e) => e.target.checked ? onSelectAll?.() : onDeselectAll?.()}
|
||||||
|
title="Выбрать все на странице"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{columns.map(renderColumnHeader)}
|
||||||
|
{hasActions && <th className="text-end">Действия</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const id = item[itemKey];
|
||||||
|
const isSelected = selectedItems.has(id);
|
||||||
|
const editing = isEditing(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={id}
|
||||||
|
className={editing ? 'table-active' : (isSelected ? 'table-selected' : '')}
|
||||||
|
style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}
|
||||||
|
>
|
||||||
|
{selectable && (
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => onSelectItem?.(id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} className={col.className}>
|
||||||
|
{renderCell(col, item)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{hasActions && (
|
||||||
|
<td className="text-end">
|
||||||
|
{renderActions(item)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={pagination.currentPage}
|
||||||
|
totalPages={pagination.totalPages}
|
||||||
|
totalItems={pagination.totalItems}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
onPageChange={pagination.onPageChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataTable;
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IconAlertTriangle, IconCopy, IconRefresh, IconChevronDown, IconChevronUp } from '@tabler/icons-react'
|
import { IconAlertTriangle, IconCopy, IconRefresh, IconChevronDown, IconChevronUp } from '@tabler/icons-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { formatDateTime } from '../lib/datetime.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ErrorAlert - улучшенный компонент для отображения ошибок
|
* ErrorAlert - улучшенный компонент для отображения ошибок
|
||||||
@@ -29,7 +30,7 @@ function ErrorAlert({
|
|||||||
Ошибка: ${errorMessage}
|
Ошибка: ${errorMessage}
|
||||||
${errorCode ? `Код: ${errorCode}` : ''}
|
${errorCode ? `Код: ${errorCode}` : ''}
|
||||||
${errorDetails ? `Детали:\n${typeof errorDetails === 'string' ? errorDetails : JSON.stringify(errorDetails, null, 2)}` : ''}
|
${errorDetails ? `Детали:\n${typeof errorDetails === 'string' ? errorDetails : JSON.stringify(errorDetails, null, 2)}` : ''}
|
||||||
Время: ${new Date().toLocaleString('ru-RU')}
|
Время: ${formatDateTime(new Date())}
|
||||||
`.trim()
|
`.trim()
|
||||||
|
|
||||||
navigator.clipboard.writeText(errorText).then(() => {
|
navigator.clipboard.writeText(errorText).then(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { IconClock, IconCheck } from '@tabler/icons-react'
|
import { IconClock, IconCheck } from '@tabler/icons-react'
|
||||||
|
import { formatDateTimeHuman } from '../lib/datetime.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LastSaved - компонент для отображения времени последнего сохранения
|
* LastSaved - компонент для отображения времени последнего сохранения
|
||||||
@@ -44,13 +45,7 @@ function LastSaved({ timestamp, variant = 'default' }) {
|
|||||||
|
|
||||||
if (!timestamp) return null
|
if (!timestamp) return null
|
||||||
|
|
||||||
const absoluteTime = new Date(timestamp).toLocaleString('ru-RU', {
|
const absoluteTime = formatDateTimeHuman(timestamp)
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Варианты отображения
|
// Варианты отображения
|
||||||
if (variant === 'badge') {
|
if (variant === 'badge') {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { IconPlus, IconAlertTriangle } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно добавления фильтра
|
||||||
|
*/
|
||||||
|
function AddFilterModal({ show, newFilter, onNewFilterChange, onAddFilter, onClose, error, communities = [] }) {
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
onNewFilterChange({ ...newFilter, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddFilter();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Добавить новый фильтр</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger">
|
||||||
|
<IconAlertTriangle className="me-2" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Community *</label>
|
||||||
|
<input
|
||||||
|
list="community-options"
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Начните вводить или выберите из списка"
|
||||||
|
value={newFilter.community}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onNewFilterChange({ ...newFilter, community: v });
|
||||||
|
const found = communities.find(c => c.value === v);
|
||||||
|
if (found && found.gatewayDefault && !newFilter.gateway) {
|
||||||
|
onNewFilterChange({ ...newFilter, community: v, gateway: found.gatewayDefault });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<datalist id="community-options">
|
||||||
|
{communities.map(c => (
|
||||||
|
<option key={c.value} value={c.value}>{c.name ? `${c.name} — ${c.value}` : c.value}</option>
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<div className="form-text">Поддерживается формат AS:NNN. Вы можете выбрать готовое значение из справочника.</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Gateway *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="SWE-HIPHOST"
|
||||||
|
value={newFilter.gateway}
|
||||||
|
onChange={(e) => handleChange('gateway', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="form-text">Название gateway для маршрутизации</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Описание</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Описание фильтра"
|
||||||
|
value={newFilter.description}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="form-text">Необязательное описание фильтра</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
<IconPlus className="me-2" />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddFilterModal;
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { IconPlus, IconAlertTriangle } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно добавления сервера для фильтров
|
||||||
|
*/
|
||||||
|
function AddFilterServerModal({ show, newServer, onNewServerChange, onAddServer, onClose, error }) {
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
onNewServerChange({ ...newServer, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddServer();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Добавить новый сервер</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger">
|
||||||
|
<IconAlertTriangle className="me-2" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Имя сервера *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Например, SWE-HIPHOST"
|
||||||
|
value={newServer.name}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Описание</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Необязательное описание сервера"
|
||||||
|
value={newServer.description}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Включен</label>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={newServer.enabled}
|
||||||
|
onChange={(e) => handleChange('enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
<IconPlus className="me-2" />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddFilterServerModal;
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { IconTrash } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно удаления фильтра
|
||||||
|
*/
|
||||||
|
function DeleteFilterModal({ show, filter, onDelete, onClose }) {
|
||||||
|
if (!show || !filter) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<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">
|
||||||
|
<p>Вы уверены, что хотите удалить фильтр?</p>
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
<strong>Community:</strong> {filter?.community || ''}<br />
|
||||||
|
<strong>Gateway:</strong> {filter?.gateway || ''}<br />
|
||||||
|
{filter?.description && <><strong>Описание:</strong> {filter.description}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger" onClick={onDelete}>
|
||||||
|
<IconTrash className="me-2" />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteFilterModal;
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { IconCheck } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно редактирования фильтра
|
||||||
|
*/
|
||||||
|
function EditFilterModal({ show, filter, onChange, onSave, onClose }) {
|
||||||
|
if (!show || !filter) return null;
|
||||||
|
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
onChange({ ...filter, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave(filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Редактировать фильтр</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Community *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="65001:200"
|
||||||
|
value={filter?.community || ''}
|
||||||
|
onChange={(e) => handleChange('community', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Gateway *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="SWE-HIPHOST"
|
||||||
|
value={filter?.gateway || ''}
|
||||||
|
onChange={(e) => handleChange('gateway', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Описание</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Описание фильтра"
|
||||||
|
value={filter?.description || ''}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
<IconCheck className="me-2" />
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditFilterModal;
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { IconFilter, IconCopy } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно предварительного просмотра конфигурации
|
||||||
|
*/
|
||||||
|
function PreviewConfigModal({ show, config, mode, onClose, onCopy }) {
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
const getModalTitle = () => {
|
||||||
|
if (mode === 'simple') {
|
||||||
|
return 'Предварительный просмотр конфигурации MikroTik (упрощённый режим)';
|
||||||
|
} else if (mode === 'advanced') {
|
||||||
|
return 'Предварительный просмотр конфигурации MikroTik (расширенный режим)';
|
||||||
|
}
|
||||||
|
return 'Предварительный просмотр конфигурации MikroTik';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getModeBadge = () => {
|
||||||
|
if (mode === 'simple') {
|
||||||
|
return <span className="badge bg-blue-lt text-blue ms-2">Упрощённый режим</span>;
|
||||||
|
} else if (mode === 'advanced') {
|
||||||
|
return <span className="badge bg-green-lt text-green ms-2">Расширенный режим</span>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfigInfo = () => {
|
||||||
|
if (config.includes('// Сначала выберите сервер') || config.includes('// Нет фильтров для генерации конфигурации')) {
|
||||||
|
return <div className="alert alert-warning mb-3">Нет данных для генерации конфигурации</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем количество фильтров из конфигурации
|
||||||
|
const communityMatches = config.match(/bgp-communities includes [^)]+/g);
|
||||||
|
const filterCount = communityMatches ? communityMatches.length : 0;
|
||||||
|
|
||||||
|
if (filterCount > 0) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-info mb-3">
|
||||||
|
<IconFilter className="me-2" />
|
||||||
|
Конфигурация сгенерирована на основе {filterCount} фильтр(ов)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal show d-block" 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">
|
||||||
|
{getModalTitle()}
|
||||||
|
{getModeBadge()}
|
||||||
|
</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{getConfigInfo()}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={onCopy}
|
||||||
|
disabled={config.includes('// Сначала выберите сервер') || config.includes('// Нет фильтров для генерации конфигурации')}
|
||||||
|
>
|
||||||
|
<IconCopy className="me-2" />
|
||||||
|
Копировать в буфер обмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-dark text-light p-3 rounded" style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||||
|
<code>{config}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PreviewConfigModal;
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as AddFilterModal } from './AddFilterModal.jsx';
|
||||||
|
export { default as EditFilterModal } from './EditFilterModal.jsx';
|
||||||
|
export { default as DeleteFilterModal } from './DeleteFilterModal.jsx';
|
||||||
|
export { default as PreviewConfigModal } from './PreviewConfigModal.jsx';
|
||||||
|
export { default as AddFilterServerModal } from './AddFilterServerModal.jsx';
|
||||||
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
|
import { SERVER_TYPE_OPTIONS } from '../../utils/serverUtils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно для добавления нового сервера
|
||||||
|
*/
|
||||||
|
function AddServerModal({ show, newServer, customProvider, onNewServerChange, onCustomProviderChange, onAddServer, onClose, error }) {
|
||||||
|
const modalRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
||||||
|
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
||||||
|
if (show) {
|
||||||
|
modalInstance.show();
|
||||||
|
} else {
|
||||||
|
modalInstance.hide();
|
||||||
|
}
|
||||||
|
const handler = () => onClose && onClose();
|
||||||
|
modalRef.current.addEventListener('hide.bs.modal', handler);
|
||||||
|
return () => {
|
||||||
|
if (modalRef.current) {
|
||||||
|
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [show, onClose]);
|
||||||
|
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
onNewServerChange({ ...newServer, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddServer();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal" tabIndex="-1" ref={modalRef}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">
|
||||||
|
<IconPlus className="icon me-2" />
|
||||||
|
Добавить новый сервер
|
||||||
|
</h5>
|
||||||
|
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">IP адрес *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
value={newServer.ip}
|
||||||
|
onChange={(e) => handleChange('ip', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">DNS имя *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="srv1.shx.su"
|
||||||
|
value={newServer.dns}
|
||||||
|
onChange={(e) => handleChange('dns', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Страна *</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={newServer.country}
|
||||||
|
onChange={(e) => handleChange('country', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Выберите страну</option>
|
||||||
|
<option value="RU">Россия (RU)</option>
|
||||||
|
<option value="SWE">Швеция (SWE)</option>
|
||||||
|
<option value="US">США (US)</option>
|
||||||
|
<option value="DE">Германия (DE)</option>
|
||||||
|
<option value="NL">Нидерланды (NL)</option>
|
||||||
|
<option value="SG">Сингапур (SG)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Провайдер *</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={newServer.provider}
|
||||||
|
onChange={(e) => handleChange('provider', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Выберите провайдера</option>
|
||||||
|
<option value="Yandex">Yandex Cloud</option>
|
||||||
|
<option value="AWS">Amazon Web Services</option>
|
||||||
|
<option value="Google">Google Cloud</option>
|
||||||
|
<option value="Azure">Microsoft Azure</option>
|
||||||
|
<option value="Hetzner">Hetzner</option>
|
||||||
|
<option value="OVH">OVH</option>
|
||||||
|
<option value="DigitalOcean">DigitalOcean</option>
|
||||||
|
<option value="Vultr">Vultr</option>
|
||||||
|
<option value="Linode">Linode</option>
|
||||||
|
<option value="VPSVILLE">VPSVILLE</option>
|
||||||
|
<option value="HIPHOST">HIPHOST</option>
|
||||||
|
<option value="Другой">Другой</option>
|
||||||
|
</select>
|
||||||
|
{newServer.provider === 'Другой' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control mt-2"
|
||||||
|
placeholder="Введите название провайдера"
|
||||||
|
value={customProvider}
|
||||||
|
onChange={(e) => onCustomProviderChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Тип сервера *</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={newServer.type}
|
||||||
|
onChange={(e) => handleChange('type', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Выберите тип</option>
|
||||||
|
{SERVER_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Тип туннеля *</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={newServer.tunnel}
|
||||||
|
onChange={(e) => handleChange('tunnel', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="GRE">GRE (Generic Routing Encapsulation)</option>
|
||||||
|
<option value="IPSec">IPSec</option>
|
||||||
|
<option value="WireGuard">WireGuard</option>
|
||||||
|
<option value="OpenVPN">OpenVPN</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Основной шлюз *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Cloudflare, Hetzner, Мой шлюз и т.д."
|
||||||
|
value={newServer.gateway}
|
||||||
|
onChange={(e) => handleChange('gateway', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
|
||||||
|
<span className="fw-bold">Отмена</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={onAddServer}>
|
||||||
|
<span className="me-2 d-flex align-items-center"><IconPlus className="icon" /></span>
|
||||||
|
<span className="fw-bold">Добавить сервер</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddServerModal;
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно подтверждения удаления сервера
|
||||||
|
*/
|
||||||
|
function DeleteServerModal({ show, server, onDelete, onClose }) {
|
||||||
|
const modalRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
||||||
|
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
||||||
|
if (show) {
|
||||||
|
modalInstance.show();
|
||||||
|
} else {
|
||||||
|
modalInstance.hide();
|
||||||
|
}
|
||||||
|
const handler = () => onClose && onClose();
|
||||||
|
modalRef.current.addEventListener('hide.bs.modal', handler);
|
||||||
|
return () => {
|
||||||
|
if (modalRef.current) {
|
||||||
|
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [show, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal" tabIndex="-1" ref={modalRef}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-status bg-danger"></div>
|
||||||
|
<div className="modal-header">
|
||||||
|
<IconAlertTriangle className="icon text-danger me-2" size={28} />
|
||||||
|
<h5 className="modal-title">Подтверждение удаления</h5>
|
||||||
|
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p>Вы уверены, что хотите удалить сервер <strong>{server?.ip}</strong>?</p>
|
||||||
|
<p className="text-muted">Это действие нельзя отменить.</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
|
||||||
|
<span className="fw-bold">Отмена</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger d-flex align-items-center" onClick={onDelete}>
|
||||||
|
<span className="fw-bold">Удалить</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteServerModal;
|
||||||
|
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
|
import {
|
||||||
|
makeGateway,
|
||||||
|
normalizeGateways,
|
||||||
|
needsGateways as NEEDS_GATEWAYS,
|
||||||
|
SERVER_TYPE_OPTIONS
|
||||||
|
} from '../../utils/serverUtils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно для редактирования сервера
|
||||||
|
*/
|
||||||
|
function EditServerModal({ show, server, onChange, onSave, onClose }) {
|
||||||
|
const modalRef = useRef(null);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
const [localServer, setLocalServer] = useState({});
|
||||||
|
|
||||||
|
const ensureGateways = (srv) => {
|
||||||
|
if (!srv) return { gateways: [] };
|
||||||
|
const needs = NEEDS_GATEWAYS(srv.type);
|
||||||
|
let gateways = [];
|
||||||
|
if (needs) {
|
||||||
|
gateways = normalizeGateways(srv.gateways, srv.gateway || srv.dns || srv.ip);
|
||||||
|
if (gateways.length === 0) {
|
||||||
|
gateways = [makeGateway({ primary: true })];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...srv, gateways };
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show && server && !initializedRef.current) {
|
||||||
|
const prepared = ensureGateways(server);
|
||||||
|
setLocalServer(prepared);
|
||||||
|
initializedRef.current = true;
|
||||||
|
}
|
||||||
|
if (!show) {
|
||||||
|
initializedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [show, server]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
||||||
|
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
||||||
|
if (show) {
|
||||||
|
modalInstance.show();
|
||||||
|
} else {
|
||||||
|
modalInstance.hide();
|
||||||
|
}
|
||||||
|
const handler = () => onClose && onClose();
|
||||||
|
modalRef.current.addEventListener('hide.bs.modal', handler);
|
||||||
|
return () => {
|
||||||
|
if (modalRef.current) {
|
||||||
|
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [show, onClose]);
|
||||||
|
|
||||||
|
const handleFieldChange = (field, value) => {
|
||||||
|
setLocalServer(prev => {
|
||||||
|
const updated = { ...prev, [field]: value };
|
||||||
|
onChange && onChange(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGatewayChange = (idx, field, value) => {
|
||||||
|
setLocalServer(prev => {
|
||||||
|
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
||||||
|
const updatedGateways = currentGateways.map((gw, i) =>
|
||||||
|
i === idx ? { ...gw, [field]: value } : gw
|
||||||
|
);
|
||||||
|
const updated = { ...prev, gateways: updatedGateways };
|
||||||
|
onChange && onChange(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrimaryChange = (idx, checked) => {
|
||||||
|
setLocalServer(prev => {
|
||||||
|
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
||||||
|
const updatedGateways = currentGateways.map((gw, i) => ({
|
||||||
|
...gw,
|
||||||
|
primary: i === idx ? checked : false
|
||||||
|
}));
|
||||||
|
if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) {
|
||||||
|
updatedGateways[idx].primary = true;
|
||||||
|
}
|
||||||
|
const updated = { ...prev, gateways: updatedGateways };
|
||||||
|
onChange && onChange(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddGateway = () => {
|
||||||
|
setLocalServer(prev => {
|
||||||
|
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
||||||
|
const newGw = makeGateway({ primary: currentGateways.length === 0 });
|
||||||
|
const updatedGateways = [...currentGateways, newGw];
|
||||||
|
const updated = { ...prev, gateways: updatedGateways };
|
||||||
|
onChange && onChange(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveGateway = (idx) => {
|
||||||
|
setLocalServer(prev => {
|
||||||
|
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
||||||
|
if (currentGateways.length <= 1) return prev;
|
||||||
|
const updatedGateways = currentGateways.filter((_, i) => i !== idx);
|
||||||
|
if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) {
|
||||||
|
updatedGateways[0].primary = true;
|
||||||
|
}
|
||||||
|
const updated = { ...prev, gateways: updatedGateways };
|
||||||
|
onChange && onChange(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentGateways = Array.isArray(localServer.gateways) ? localServer.gateways : [];
|
||||||
|
const showGateways = NEEDS_GATEWAYS(localServer.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal" tabIndex="-1" ref={modalRef}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Редактировать сервер</h5>
|
||||||
|
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<form onSubmit={e => { e.preventDefault(); onSave && onSave(localServer); }}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">ID (опционально)</label>
|
||||||
|
<input type="text" className="form-control" value={localServer.id || ''} onChange={e => handleFieldChange('id', e.target.value)} placeholder="srv-1" />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">IP адрес</label>
|
||||||
|
<input type="text" className="form-control" value={localServer.ip || ''} onChange={e => handleFieldChange('ip', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Внешний IP (опционально)</label>
|
||||||
|
<input type="text" className="form-control" value={localServer.extIp || ''} onChange={e => handleFieldChange('extIp', e.target.value)} placeholder="публичный IP, если отличается" />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Внутренний IP (опционально)</label>
|
||||||
|
<input type="text" className="form-control" value={localServer.internalIp || ''} onChange={e => handleFieldChange('internalIp', e.target.value)} placeholder="10.x.x.x / 192.168.x.x" />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">DNS имя</label>
|
||||||
|
<input type="text" className="form-control" value={localServer.dns || ''} onChange={e => handleFieldChange('dns', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Страна</label>
|
||||||
|
<input type="text" className="form-control" value={localServer.country || ''} onChange={e => handleFieldChange('country', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Провайдер</label>
|
||||||
|
<input type="text" className="form-control" value={localServer.provider || ''} onChange={e => handleFieldChange('provider', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Тип сервера</label>
|
||||||
|
<select className="form-select" value={localServer.type || 'jumphost'} onChange={e => handleFieldChange('type', e.target.value)}>
|
||||||
|
{SERVER_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Тип туннеля</label>
|
||||||
|
<input type="text" className="form-control" value={localServer.tunnel || ''} onChange={e => handleFieldChange('tunnel', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{showGateways && (
|
||||||
|
<>
|
||||||
|
<div className="form-label">Шлюзы ({currentGateways.length})</div>
|
||||||
|
<div className="list-group list-group-flush border rounded mb-2">
|
||||||
|
{currentGateways.map((gw, idx) => (
|
||||||
|
<div key={gw.id || `gw-${idx}`} className="list-group-item">
|
||||||
|
<div className="row g-2 align-items-end">
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<label className="form-label small mb-1">Имя *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={gw.name || ''}
|
||||||
|
onChange={(e) => handleGatewayChange(idx, 'name', e.target.value)}
|
||||||
|
placeholder="GW-MSK"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<label className="form-label small mb-1">IP</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={gw.ip || ''}
|
||||||
|
onChange={(e) => handleGatewayChange(idx, 'ip', e.target.value)}
|
||||||
|
placeholder="10.0.0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-3">
|
||||||
|
<label className="form-label small mb-1">Комментарий</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={gw.comment || ''}
|
||||||
|
onChange={(e) => handleGatewayChange(idx, 'comment', e.target.value)}
|
||||||
|
placeholder="Основной шлюз"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-1 d-flex align-items-center justify-content-center">
|
||||||
|
<div className="form-check form-switch m-0">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!gw.primary}
|
||||||
|
onChange={(e) => handlePrimaryChange(idx, e.target.checked)}
|
||||||
|
title="Основной шлюз"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between align-items-center mt-2">
|
||||||
|
<div className="text-muted small">{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}</div>
|
||||||
|
{currentGateways.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link text-danger px-0"
|
||||||
|
onClick={() => handleRemoveGateway(idx)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
onClick={handleAddGateway}
|
||||||
|
>
|
||||||
|
<IconPlus className="icon" />
|
||||||
|
Добавить шлюз
|
||||||
|
</button>
|
||||||
|
<div className="form-text">Для jumphost/exit должен быть один основной gateway.</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
|
||||||
|
<span className="fw-bold">Отмена</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={() => onSave && onSave(localServer)}>
|
||||||
|
<span className="fw-bold">Сохранить</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditServerModal;
|
||||||
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { IconLink, IconDownload } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно генератора ссылок для сервера
|
||||||
|
*/
|
||||||
|
function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, onGenerateUrl, onCopyToClipboard, onClose }) {
|
||||||
|
const modalRef = useRef(null);
|
||||||
|
const [localUrlSettings, setLocalUrlSettings] = useState(urlSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalUrlSettings(urlSettings);
|
||||||
|
}, [urlSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
||||||
|
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
||||||
|
if (show) {
|
||||||
|
modalInstance.show();
|
||||||
|
} else {
|
||||||
|
modalInstance.hide();
|
||||||
|
}
|
||||||
|
const handler = () => onClose && onClose();
|
||||||
|
modalRef.current.addEventListener('hide.bs.modal', handler);
|
||||||
|
return () => {
|
||||||
|
if (modalRef.current) {
|
||||||
|
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [show, onClose]);
|
||||||
|
|
||||||
|
const handleSettingChange = (key, value) => {
|
||||||
|
const updated = { ...localUrlSettings, [key]: value };
|
||||||
|
setLocalUrlSettings(updated);
|
||||||
|
onUrlSettingsChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSettings = () => {
|
||||||
|
onUrlSettingsChange(localUrlSettings);
|
||||||
|
localStorage.setItem('urlSettings', JSON.stringify(localUrlSettings));
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatedUrl = server ? onGenerateUrl(server) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal modal-lg" tabIndex="-1" ref={modalRef}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">
|
||||||
|
<IconLink className="icon me-2" />
|
||||||
|
Генератор ссылок для {server?.ip}
|
||||||
|
</h5>
|
||||||
|
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<h6 className="mb-3">Настройки URL</h6>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Базовый URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={localUrlSettings.baseUrl}
|
||||||
|
onChange={e => handleSettingChange('baseUrl', e.target.value)}
|
||||||
|
placeholder="https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Cloudflare Gateway</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={localUrlSettings.cloudflare_gateway}
|
||||||
|
onChange={e => handleSettingChange('cloudflare_gateway', e.target.value)}
|
||||||
|
placeholder="SWE-IHOR или IP"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Bunny Gateway</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={localUrlSettings.bunny_gateway}
|
||||||
|
onChange={e => handleSettingChange('bunny_gateway', e.target.value)}
|
||||||
|
placeholder="SWE-IHOR или IP"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Fastly Gateway</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={localUrlSettings.fastly_gateway}
|
||||||
|
onChange={e => handleSettingChange('fastly_gateway', e.target.value)}
|
||||||
|
placeholder="SWE-IHOR или IP"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Telegram Gateway</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={localUrlSettings.telegram_gateway}
|
||||||
|
onChange={e => handleSettingChange('telegram_gateway', e.target.value)}
|
||||||
|
placeholder="IP адрес"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Hetzner Gateway</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={localUrlSettings.hetzner_gateway}
|
||||||
|
onChange={e => handleSettingChange('hetzner_gateway', e.target.value)}
|
||||||
|
placeholder="IP адрес"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Тип</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={localUrlSettings.type}
|
||||||
|
onChange={e => handleSettingChange('type', e.target.value)}
|
||||||
|
placeholder="routes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Версия</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={localUrlSettings.version}
|
||||||
|
onChange={e => handleSettingChange('version', e.target.value)}
|
||||||
|
placeholder="v4.rsc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h6 className="mb-3">Сгенерированная ссылка</h6>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={generatedUrl}
|
||||||
|
readOnly
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary d-flex align-items-center"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCopyToClipboard(generatedUrl)}
|
||||||
|
title="Копировать в буфер обмена"
|
||||||
|
>
|
||||||
|
<span className="me-2 d-flex align-items-center"><IconDownload size={18} /></span>
|
||||||
|
<span className="fw-bold">Копировать</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
|
||||||
|
<span className="fw-bold">Закрыть</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={handleSaveSettings}>
|
||||||
|
<span className="fw-bold">Сохранить настройки</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkGeneratorModal;
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as EditServerModal } from './EditServerModal.jsx';
|
||||||
|
export { default as DeleteServerModal } from './DeleteServerModal.jsx';
|
||||||
|
export { default as LinkGeneratorModal } from './LinkGeneratorModal.jsx';
|
||||||
|
export { default as AddServerModal } from './AddServerModal.jsx';
|
||||||
|
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
/**
|
||||||
|
* React Query хуки для работы с API
|
||||||
|
* Централизованное управление данными с кэшированием и автообновлением
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../lib/api.js';
|
||||||
|
|
||||||
|
// Ключи запросов
|
||||||
|
export const queryKeys = {
|
||||||
|
domains: ['domains'],
|
||||||
|
ipRanges: ['ipRanges'],
|
||||||
|
asns: ['asns'],
|
||||||
|
servers: ['servers'],
|
||||||
|
communities: ['communities'],
|
||||||
|
filters: ['filters'],
|
||||||
|
serverFilters: (serverId) => ['serverFilters', serverId],
|
||||||
|
serverConfigs: ['serverConfigs'],
|
||||||
|
serverConfig: (serverId) => ['serverConfig', serverId],
|
||||||
|
billing: ['billing'],
|
||||||
|
s3LastModified: ['s3LastModified'],
|
||||||
|
serversAvailability: ['serversAvailability'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Конфигурация по умолчанию
|
||||||
|
const defaultQueryOptions = {
|
||||||
|
staleTime: 30_000, // 30 секунд
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения доменов
|
||||||
|
*/
|
||||||
|
export function useDomains(options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.domains,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/domains-new', { params: { offset: 0, limit: 0, format: 'std' } });
|
||||||
|
return {
|
||||||
|
items: Array.isArray(res.data?.items) ? res.data.items : [],
|
||||||
|
etag: res.headers?.etag || '',
|
||||||
|
lastModified: res.headers?.['last-modified'] || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
...defaultQueryOptions,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутация для сохранения доменов
|
||||||
|
*/
|
||||||
|
export function useSaveDomains() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ domains, etag }) => {
|
||||||
|
const res = await api.post('/domains-new', { domains, etag }, { validateStatus: () => true });
|
||||||
|
if (res.status === 412) {
|
||||||
|
throw new Error('ETag mismatch - данные изменились');
|
||||||
|
}
|
||||||
|
if (res.status >= 400) {
|
||||||
|
throw new Error(`Ошибка сохранения: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.domains });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения IP-диапазонов
|
||||||
|
*/
|
||||||
|
export function useIpRanges(options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.ipRanges,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/ip-ranges', { params: { offset: 0, limit: 0, format: 'std' } });
|
||||||
|
return {
|
||||||
|
items: Array.isArray(res.data?.items) ? res.data.items : [],
|
||||||
|
etag: res.headers?.etag || '',
|
||||||
|
lastModified: res.headers?.['last-modified'] || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
...defaultQueryOptions,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутация для сохранения IP-диапазонов
|
||||||
|
*/
|
||||||
|
export function useSaveIpRanges() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ ipRanges, etag }) => {
|
||||||
|
const res = await api.post('/ip-ranges', { ipRanges, etag }, { validateStatus: () => true });
|
||||||
|
if (res.status === 412) {
|
||||||
|
throw new Error('ETag mismatch - данные изменились');
|
||||||
|
}
|
||||||
|
if (res.status >= 400) {
|
||||||
|
throw new Error(`Ошибка сохранения: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.ipRanges });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения ASN
|
||||||
|
*/
|
||||||
|
export function useAsns(options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.asns,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/asns', { params: { offset: 0, limit: 0, format: 'std' } });
|
||||||
|
const payload = Array.isArray(res.data?.items) ? res.data.items : [];
|
||||||
|
// Преобразуем формат API в формат компонента
|
||||||
|
const items = payload.map(item => ({
|
||||||
|
asn: String(item.domain),
|
||||||
|
community: String(item.type)
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
etag: res.headers?.etag || '',
|
||||||
|
lastModified: res.headers?.['last-modified'] || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
...defaultQueryOptions,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутация для сохранения ASN
|
||||||
|
*/
|
||||||
|
export function useSaveAsns() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ asns, etag }) => {
|
||||||
|
// Преобразуем обратно в формат API
|
||||||
|
const domains = asns.map(a => ({ domain: a.asn, type: a.community }));
|
||||||
|
const res = await api.post('/asns', { domains, etag }, { validateStatus: () => true });
|
||||||
|
if (res.status === 412) {
|
||||||
|
throw new Error('ETag mismatch - данные изменились');
|
||||||
|
}
|
||||||
|
if (res.status >= 400) {
|
||||||
|
throw new Error(`Ошибка сохранения: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.asns });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения серверов
|
||||||
|
*/
|
||||||
|
export function useServers(options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.servers,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/servers');
|
||||||
|
return {
|
||||||
|
items: Array.isArray(res.data) ? res.data : [],
|
||||||
|
etag: res.headers?.etag || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
...defaultQueryOptions,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутация для сохранения серверов
|
||||||
|
*/
|
||||||
|
export function useSaveServers() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ servers, etag }) => {
|
||||||
|
const res = await api.post('/servers', { servers, etag }, { validateStatus: () => true });
|
||||||
|
if (res.status === 412) {
|
||||||
|
throw new Error('ETag mismatch - данные изменились');
|
||||||
|
}
|
||||||
|
if (res.status >= 400) {
|
||||||
|
throw new Error(`Ошибка сохранения: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения community справочника
|
||||||
|
*/
|
||||||
|
export function useCommunities(options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.communities,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/communities');
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
},
|
||||||
|
staleTime: 60_000, // 1 минута - справочник меняется редко
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутация для сохранения community справочника
|
||||||
|
*/
|
||||||
|
export function useSaveCommunities() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ communities }) => {
|
||||||
|
const res = await api.post('/communities', { communities });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.communities });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения фильтров сервера
|
||||||
|
*/
|
||||||
|
export function useServerFilters(serverId, options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.serverFilters(serverId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get(`/server-filters/${serverId}`);
|
||||||
|
return {
|
||||||
|
filters: Array.isArray(res.data?.filters) ? res.data.filters : [],
|
||||||
|
etag: res.headers?.etag || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!serverId,
|
||||||
|
...defaultQueryOptions,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутация для сохранения фильтров сервера
|
||||||
|
*/
|
||||||
|
export function useSaveServerFilters(serverId) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ filters, etag }) => {
|
||||||
|
const res = await api.post(`/server-filters/${serverId}`, { filters, etag }, { validateStatus: () => true });
|
||||||
|
if (res.status === 412) {
|
||||||
|
throw new Error('ETag mismatch - данные изменились');
|
||||||
|
}
|
||||||
|
if (res.status >= 400) {
|
||||||
|
throw new Error(`Ошибка сохранения: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.serverFilters(serverId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения статистики S3
|
||||||
|
*/
|
||||||
|
export function useS3LastModified(options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.s3LastModified,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/s3/last-modified');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения доступности серверов
|
||||||
|
*/
|
||||||
|
export function useServersAvailability(options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.serversAvailability,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/servers/availability', { params: { ttlSeconds: 60 } });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения биллинга
|
||||||
|
*/
|
||||||
|
export function useBilling(options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.billing,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/billing');
|
||||||
|
return {
|
||||||
|
items: Array.isArray(res.data) ? res.data : [],
|
||||||
|
etag: res.headers?.etag || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
...defaultQueryOptions,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутация для сохранения биллинга
|
||||||
|
*/
|
||||||
|
export function useSaveBilling() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ items, etag }) => {
|
||||||
|
const res = await api.post('/billing', { items, etag }, { validateStatus: () => true });
|
||||||
|
if (res.status === 412) {
|
||||||
|
throw new Error('ETag mismatch - данные изменились');
|
||||||
|
}
|
||||||
|
if (res.status >= 400) {
|
||||||
|
throw new Error(`Ошибка сохранения: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.billing });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальный хук для получения количества записей
|
||||||
|
*/
|
||||||
|
export function useDataCount(endpoint, options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [endpoint, 'count'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get(endpoint, { params: { countOnly: true } });
|
||||||
|
return res.data?.total ?? 0;
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { formatDateTime } from './datetime.js';
|
||||||
import {
|
import {
|
||||||
getErrorType,
|
getErrorType,
|
||||||
isRetriableError,
|
isRetriableError,
|
||||||
@@ -117,7 +118,7 @@ api.interceptors.response.use(
|
|||||||
const extraDetails = {
|
const extraDetails = {
|
||||||
...errorDetails,
|
...errorDetails,
|
||||||
action: actionMessage,
|
action: actionMessage,
|
||||||
timestamp: new Date().toLocaleString('ru-RU')
|
timestamp: formatDateTime(new Date())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Выбираем тип уведомления
|
// Выбираем тип уведомления
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ function pad(num) {
|
|||||||
return String(num).padStart(2, '0');
|
return String(num).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует дату в формате DD.MM.YYYY HH:mm:ss
|
||||||
|
* @param {Date|string|number} input - Дата
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
export function formatDateTime(input) {
|
export function formatDateTime(input) {
|
||||||
if (!input) return '';
|
if (!input) return '';
|
||||||
const d = input instanceof Date ? input : new Date(input);
|
const d = input instanceof Date ? input : new Date(input);
|
||||||
@@ -15,6 +20,64 @@ export function formatDateTime(input) {
|
|||||||
return `${day}.${month}.${year} ${h}:${m}:${s}`;
|
return `${day}.${month}.${year} ${h}:${m}:${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует дату в формате DD.MM.YYYY HH:mm (без секунд)
|
||||||
|
* @param {Date|string|number} input - Дата
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatDateTimeShort(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
const d = input instanceof Date ? input : new Date(input);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const day = pad(d.getDate());
|
||||||
|
const month = pad(d.getMonth() + 1);
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const h = pad(d.getHours());
|
||||||
|
const m = pad(d.getMinutes());
|
||||||
|
return `${day}.${month}.${year} ${h}:${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует время в формате HH:mm:ss
|
||||||
|
* @param {Date|string|number} input - Дата
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatTime(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
const d = input instanceof Date ? input : new Date(input);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const h = pad(d.getHours());
|
||||||
|
const m = pad(d.getMinutes());
|
||||||
|
const s = pad(d.getSeconds());
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает текущую дату/время в формате DD.MM.YYYY HH:mm:ss
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function now() {
|
||||||
|
return formatDateTime(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует дату в человекочитаемом формате: "24 декабря 2025, 15:30"
|
||||||
|
* @param {Date|string|number} input - Дата
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatDateTimeHuman(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
const d = input instanceof Date ? input : new Date(input);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function formatRelative(input) {
|
export function formatRelative(input) {
|
||||||
if (!input) return '';
|
if (!input) return '';
|
||||||
const d = input instanceof Date ? input : new Date(input);
|
const d = input instanceof Date ? input : new Date(input);
|
||||||
|
|||||||
Reference in New Issue
Block a user