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 { Link } from 'react-router-dom';
|
||||
import api from './lib/api.js';
|
||||
import { formatDateTime } from './lib/datetime.js';
|
||||
import {
|
||||
IconWorld,
|
||||
IconNetwork,
|
||||
@@ -130,7 +131,7 @@ function Dashboard() {
|
||||
// Получаем дату последнего обновления
|
||||
// Новый формат: объект с ключами { domainsNew, asns, servers, filters, ipRanges }
|
||||
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'
|
||||
? domainsRes.value.data.total
|
||||
|
||||
@@ -33,6 +33,13 @@ import {
|
||||
IconFileText
|
||||
} from '@tabler/icons-react';
|
||||
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
|
||||
import {
|
||||
AddFilterModal,
|
||||
EditFilterModal,
|
||||
DeleteFilterModal,
|
||||
PreviewConfigModal,
|
||||
AddFilterServerModal
|
||||
} from './components/filter/index.js';
|
||||
|
||||
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;
|
||||
@@ -31,6 +31,12 @@ import {
|
||||
needsGateways as NEEDS_GATEWAYS,
|
||||
SERVER_TYPE_OPTIONS
|
||||
} from './utils/serverUtils.js';
|
||||
import {
|
||||
EditServerModal,
|
||||
DeleteServerModal,
|
||||
LinkGeneratorModal,
|
||||
AddServerModal
|
||||
} from './components/server/index.js';
|
||||
|
||||
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;
|
||||
@@ -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 { useState } from 'react'
|
||||
import { formatDateTime } from '../lib/datetime.js'
|
||||
|
||||
/**
|
||||
* ErrorAlert - улучшенный компонент для отображения ошибок
|
||||
@@ -29,7 +30,7 @@ function ErrorAlert({
|
||||
Ошибка: ${errorMessage}
|
||||
${errorCode ? `Код: ${errorCode}` : ''}
|
||||
${errorDetails ? `Детали:\n${typeof errorDetails === 'string' ? errorDetails : JSON.stringify(errorDetails, null, 2)}` : ''}
|
||||
Время: ${new Date().toLocaleString('ru-RU')}
|
||||
Время: ${formatDateTime(new Date())}
|
||||
`.trim()
|
||||
|
||||
navigator.clipboard.writeText(errorText).then(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { IconClock, IconCheck } from '@tabler/icons-react'
|
||||
import { formatDateTimeHuman } from '../lib/datetime.js'
|
||||
|
||||
/**
|
||||
* LastSaved - компонент для отображения времени последнего сохранения
|
||||
@@ -44,13 +45,7 @@ function LastSaved({ timestamp, variant = 'default' }) {
|
||||
|
||||
if (!timestamp) return null
|
||||
|
||||
const absoluteTime = new Date(timestamp).toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
const absoluteTime = formatDateTimeHuman(timestamp)
|
||||
|
||||
// Варианты отображения
|
||||
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 { formatDateTime } from './datetime.js';
|
||||
import {
|
||||
getErrorType,
|
||||
isRetriableError,
|
||||
@@ -117,7 +118,7 @@ api.interceptors.response.use(
|
||||
const extraDetails = {
|
||||
...errorDetails,
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату в формате DD.MM.YYYY HH:mm:ss
|
||||
* @param {Date|string|number} input - Дата
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDateTime(input) {
|
||||
if (!input) return '';
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
@@ -15,6 +20,64 @@ export function formatDateTime(input) {
|
||||
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) {
|
||||
if (!input) return '';
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
|
||||
Reference in New Issue
Block a user