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

This commit is contained in:
2025-12-24 15:52:01 +07:00
parent 6e7a63dd28
commit 27dade3eda
20 changed files with 1832 additions and 1045 deletions
+2 -1
View File
@@ -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
+7 -362
View File
@@ -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;
+6 -673
View File
@@ -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;
+289
View File
@@ -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;
+2 -1
View File
@@ -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(() => {
+2 -7
View File
@@ -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;
+6
View File
@@ -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;
+5
View File
@@ -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';
+365
View File
@@ -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,
});
}
+2 -1
View File
@@ -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())
};
// Выбираем тип уведомления
+63
View File
@@ -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);