diff --git a/frontend/src/Dashboard.jsx b/frontend/src/Dashboard.jsx index 5aca56f..9eaac77 100644 --- a/frontend/src/Dashboard.jsx +++ b/frontend/src/Dashboard.jsx @@ -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 diff --git a/frontend/src/FilterManager.jsx b/frontend/src/FilterManager.jsx index 9c93088..c96622b 100644 --- a/frontend/src/FilterManager.jsx +++ b/frontend/src/FilterManager.jsx @@ -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 ( -
-
-
-
-
Добавить новый фильтр
- -
-
-
- {error && ( -
- - {error} -
- )} -
- - { - 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 - /> - - {communities.map(c => ( - - ))} - -
Поддерживается формат AS:NNN. Вы можете выбрать готовое значение из справочника.
-
-
- - handleChange('gateway', e.target.value)} - required - /> -
Название gateway для маршрутизации
-
-
- - handleChange('description', e.target.value)} - /> -
Необязательное описание фильтра
-
-
-
- - -
-
-
-
-
- ); -} - -// Модальное окно редактирования фильтра -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 ( -
-
-
-
-
Редактировать фильтр
- -
-
-
-
- - handleChange('community', e.target.value)} - required - /> -
-
- - handleChange('gateway', e.target.value)} - required - /> -
-
- - handleChange('description', e.target.value)} - /> -
-
-
- - -
-
-
-
-
- ); -} - -// Модальное окно удаления фильтра -function DeleteFilterModal({ show, filter, onDelete, onClose }) { - if (!show || !filter) return null; - - return ( -
-
-
-
-
Подтверждение удаления
- -
-
-

Вы уверены, что хотите удалить фильтр?

-
- Community: {filter?.community || ''}
- Gateway: {filter?.gateway || ''}
- {filter?.description && <>Описание: {filter.description}} -
-
-
- - -
-
-
-
- ); -} - -// Модальное окно предварительного просмотра конфигурации -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 Упрощённый режим; - } else if (mode === 'advanced') { - return Расширенный режим; - } - return null; - }; - - const getConfigInfo = () => { - if (config.includes('// Сначала выберите сервер') || config.includes('// Нет фильтров для генерации конфигурации')) { - return
Нет данных для генерации конфигурации
; - } - - // Подсчитываем количество фильтров из конфигурации - const communityMatches = config.match(/bgp-communities includes [^)]+/g); - const filterCount = communityMatches ? communityMatches.length : 0; - - if (filterCount > 0) { - return ( -
- - Конфигурация сгенерирована на основе {filterCount} фильтр(ов) -
- ); - } - - return null; - }; - - return ( -
-
-
-
-
- {getModalTitle()} - {getModeBadge()} -
- -
-
- {getConfigInfo()} -
-
- -
-
-
-              {config}
-            
-
-
- -
-
-
-
- ); -} - -// Модальное окно добавления сервера -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 ( -
-
-
-
-
Добавить новый сервер
- -
-
-
- {error && ( -
- - {error} -
- )} -
- - handleChange('name', e.target.value)} - required - /> -
-
- - handleChange('description', e.target.value)} - /> -
-
- -
- handleChange('enabled', e.target.checked)} - /> -
-
-
-
- - -
-
-
-
-
- ); -} - export default FilterManager; \ No newline at end of file diff --git a/frontend/src/ServerManager.jsx b/frontend/src/ServerManager.jsx index c576e4a..ba01030 100644 --- a/frontend/src/ServerManager.jsx +++ b/frontend/src/ServerManager.jsx @@ -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 ( -
-
-
-
-
Редактировать сервер
- -
-
-
{ e.preventDefault(); onSave && onSave(localServer); }}> -
- - handleFieldChange('id', e.target.value)} placeholder="srv-1" /> -
-
- - handleFieldChange('ip', e.target.value)} /> -
-
- - handleFieldChange('extIp', e.target.value)} placeholder="публичный IP, если отличается" /> -
-
- - handleFieldChange('internalIp', e.target.value)} placeholder="10.x.x.x / 192.168.x.x" /> -
-
- - handleFieldChange('dns', e.target.value)} /> -
-
- - handleFieldChange('country', e.target.value)} /> -
-
- - handleFieldChange('provider', e.target.value)} /> -
-
- - -
-
- - handleFieldChange('tunnel', e.target.value)} /> -
- {showGateways && ( - <> -
Шлюзы ({currentGateways.length})
-
- {currentGateways.map((gw, idx) => ( -
-
-
- - handleGatewayChange(idx, 'name', e.target.value)} - placeholder="GW-MSK" - required - /> -
-
- - handleGatewayChange(idx, 'ip', e.target.value)} - placeholder="10.0.0.1" - /> -
-
- - handleGatewayChange(idx, 'comment', e.target.value)} - placeholder="Основной шлюз" - /> -
-
-
- handlePrimaryChange(idx, e.target.checked)} - title="Основной шлюз" - /> -
-
-
-
-
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
- {currentGateways.length > 1 && ( - - )} -
-
- ))} -
- -
Для jumphost/exit должен быть один основной gateway.
- - )} -
-
-
- - -
-
-
-
- ); -} - -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 ( -
-
-
-
-
- -
Подтверждение удаления
- -
-
-

Вы уверены, что хотите удалить сервер {server?.ip}?

-

Это действие нельзя отменить.

-
-
- - -
-
-
-
- ); -} - -// Модальное окно для генератора ссылок -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 ( -
-
-
-
-
- - Генератор ссылок для {server?.ip} -
- -
-
-
-
-
Настройки URL
-
- - handleSettingChange('baseUrl', e.target.value)} - placeholder="https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo" - /> -
-
- - handleSettingChange('cloudflare_gateway', e.target.value)} - placeholder="SWE-IHOR или IP" - /> -
-
- - handleSettingChange('bunny_gateway', e.target.value)} - placeholder="SWE-IHOR или IP" - /> -
-
- - handleSettingChange('fastly_gateway', e.target.value)} - placeholder="SWE-IHOR или IP" - /> -
-
-
-
- - handleSettingChange('telegram_gateway', e.target.value)} - placeholder="IP адрес" - /> -
-
- - handleSettingChange('hetzner_gateway', e.target.value)} - placeholder="IP адрес" - /> -
-
- - handleSettingChange('type', e.target.value)} - placeholder="routes" - /> -
-
- - handleSettingChange('version', e.target.value)} - placeholder="v4.rsc" - /> -
-
-
- -
-
Сгенерированная ссылка
-
- - -
-
-
-
- - -
-
-
-
- ); -} - -// Модальное окно для добавления нового сервера -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 ( -
-
-
-
-
- - Добавить новый сервер -
- -
-
- {error && ( -
- {error} -
- )} -
-
- - handleChange('ip', e.target.value)} - required - /> -
-
- - handleChange('dns', e.target.value)} - required - /> -
-
- - -
-
- - - {newServer.provider === 'Другой' && ( - onCustomProviderChange(e.target.value)} - /> - )} -
-
- - -
-
- - -
-
- - handleChange('gateway', e.target.value)} - required - /> -
-
-
-
- - -
-
-
-
- ); -} - export default ServerManager; \ No newline at end of file diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx new file mode 100644 index 0000000..8da21f6 --- /dev/null +++ b/frontend/src/components/DataTable.jsx @@ -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 = ( +
+ {col.icon && } + {col.title} + {isSortable && isActive && ( + + {sortOrder === 'asc' ? : } + + )} +
+ ); + + if (isSortable) { + return ( + onSort(col.key)} + style={col.width ? { width: col.width } : undefined} + > + {content} + + ); + } + + return ( + + {content} + + ); + }; + + // Рендер ячейки + 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)} + + + + + + + + ); + } + + return ( + <> + {onEdit && ( + + + + )} + {onCopy && ( + + + + )} + {actions.map((action, idx) => ( + + + + ))} + {onDelete && ( + + + + )} + + ); + }; + + // Загрузка + if (loading) { + return ; + } + + // Пустое состояние + if (items.length === 0) { + return ( + + {emptyState ? ( + + ) : ( + + )} + + ); + } + + const allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey])); + + return ( + <> +
+ + + + {selectable && ( + + )} + {columns.map(renderColumnHeader)} + {hasActions && } + + + + {items.map((item, idx) => { + const id = item[itemKey]; + const isSelected = selectedItems.has(id); + const editing = isEditing(item); + + return ( + + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + {hasActions && ( + + )} + + ); + })} + +
+ e.target.checked ? onSelectAll?.() : onDeselectAll?.()} + title="Выбрать все на странице" + /> + Действия
+ onSelectItem?.(id)} + onClick={(e) => e.stopPropagation()} + /> + + {renderCell(col, item)} + + {renderActions(item)} +
+
+ + {pagination && ( + + )} + + ); +} + +export default DataTable; + diff --git a/frontend/src/components/ErrorAlert.jsx b/frontend/src/components/ErrorAlert.jsx index 235af00..518bf9f 100644 --- a/frontend/src/components/ErrorAlert.jsx +++ b/frontend/src/components/ErrorAlert.jsx @@ -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(() => { diff --git a/frontend/src/components/LastSaved.jsx b/frontend/src/components/LastSaved.jsx index 0434c7d..b577a60 100644 --- a/frontend/src/components/LastSaved.jsx +++ b/frontend/src/components/LastSaved.jsx @@ -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') { diff --git a/frontend/src/components/filter/AddFilterModal.jsx b/frontend/src/components/filter/AddFilterModal.jsx new file mode 100644 index 0000000..7d85462 --- /dev/null +++ b/frontend/src/components/filter/AddFilterModal.jsx @@ -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 ( +
+
+
+
+
Добавить новый фильтр
+ +
+
+
+ {error && ( +
+ + {error} +
+ )} +
+ + { + 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 + /> + + {communities.map(c => ( + + ))} + +
Поддерживается формат AS:NNN. Вы можете выбрать готовое значение из справочника.
+
+
+ + handleChange('gateway', e.target.value)} + required + /> +
Название gateway для маршрутизации
+
+
+ + handleChange('description', e.target.value)} + /> +
Необязательное описание фильтра
+
+
+
+ + +
+
+
+
+
+ ); +} + +export default AddFilterModal; + diff --git a/frontend/src/components/filter/AddFilterServerModal.jsx b/frontend/src/components/filter/AddFilterServerModal.jsx new file mode 100644 index 0000000..ceccf80 --- /dev/null +++ b/frontend/src/components/filter/AddFilterServerModal.jsx @@ -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 ( +
+
+
+
+
Добавить новый сервер
+ +
+
+
+ {error && ( +
+ + {error} +
+ )} +
+ + handleChange('name', e.target.value)} + required + /> +
+
+ + handleChange('description', e.target.value)} + /> +
+
+ +
+ handleChange('enabled', e.target.checked)} + /> +
+
+
+
+ + +
+
+
+
+
+ ); +} + +export default AddFilterServerModal; + diff --git a/frontend/src/components/filter/DeleteFilterModal.jsx b/frontend/src/components/filter/DeleteFilterModal.jsx new file mode 100644 index 0000000..0772069 --- /dev/null +++ b/frontend/src/components/filter/DeleteFilterModal.jsx @@ -0,0 +1,41 @@ +import { IconTrash } from '@tabler/icons-react'; + +/** + * Модальное окно удаления фильтра + */ +function DeleteFilterModal({ show, filter, onDelete, onClose }) { + if (!show || !filter) return null; + + return ( +
+
+
+
+
Подтверждение удаления
+ +
+
+

Вы уверены, что хотите удалить фильтр?

+
+ Community: {filter?.community || ''}
+ Gateway: {filter?.gateway || ''}
+ {filter?.description && <>Описание: {filter.description}} +
+
+
+ + +
+
+
+
+ ); +} + +export default DeleteFilterModal; + diff --git a/frontend/src/components/filter/EditFilterModal.jsx b/frontend/src/components/filter/EditFilterModal.jsx new file mode 100644 index 0000000..9b7bdd5 --- /dev/null +++ b/frontend/src/components/filter/EditFilterModal.jsx @@ -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 ( +
+
+
+
+
Редактировать фильтр
+ +
+
+
+
+ + handleChange('community', e.target.value)} + required + /> +
+
+ + handleChange('gateway', e.target.value)} + required + /> +
+
+ + handleChange('description', e.target.value)} + /> +
+
+
+ + +
+
+
+
+
+ ); +} + +export default EditFilterModal; + diff --git a/frontend/src/components/filter/PreviewConfigModal.jsx b/frontend/src/components/filter/PreviewConfigModal.jsx new file mode 100644 index 0000000..e197463 --- /dev/null +++ b/frontend/src/components/filter/PreviewConfigModal.jsx @@ -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 Упрощённый режим; + } else if (mode === 'advanced') { + return Расширенный режим; + } + return null; + }; + + const getConfigInfo = () => { + if (config.includes('// Сначала выберите сервер') || config.includes('// Нет фильтров для генерации конфигурации')) { + return
Нет данных для генерации конфигурации
; + } + + // Подсчитываем количество фильтров из конфигурации + const communityMatches = config.match(/bgp-communities includes [^)]+/g); + const filterCount = communityMatches ? communityMatches.length : 0; + + if (filterCount > 0) { + return ( +
+ + Конфигурация сгенерирована на основе {filterCount} фильтр(ов) +
+ ); + } + + return null; + }; + + return ( +
+
+
+
+
+ {getModalTitle()} + {getModeBadge()} +
+ +
+
+ {getConfigInfo()} +
+
+ +
+
+
+              {config}
+            
+
+
+ +
+
+
+
+ ); +} + +export default PreviewConfigModal; + diff --git a/frontend/src/components/filter/index.js b/frontend/src/components/filter/index.js new file mode 100644 index 0000000..7af523a --- /dev/null +++ b/frontend/src/components/filter/index.js @@ -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'; + diff --git a/frontend/src/components/server/AddServerModal.jsx b/frontend/src/components/server/AddServerModal.jsx new file mode 100644 index 0000000..ab774e2 --- /dev/null +++ b/frontend/src/components/server/AddServerModal.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 ( +
+
+
+
+
+ + Добавить новый сервер +
+ +
+
+ {error && ( +
+ {error} +
+ )} +
+
+ + handleChange('ip', e.target.value)} + required + /> +
+
+ + handleChange('dns', e.target.value)} + required + /> +
+
+ + +
+
+ + + {newServer.provider === 'Другой' && ( + onCustomProviderChange(e.target.value)} + /> + )} +
+
+ + +
+
+ + +
+
+ + handleChange('gateway', e.target.value)} + required + /> +
+
+
+
+ + +
+
+
+
+ ); +} + +export default AddServerModal; + diff --git a/frontend/src/components/server/DeleteServerModal.jsx b/frontend/src/components/server/DeleteServerModal.jsx new file mode 100644 index 0000000..46998e8 --- /dev/null +++ b/frontend/src/components/server/DeleteServerModal.jsx @@ -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 ( +
+
+
+
+
+ +
Подтверждение удаления
+ +
+
+

Вы уверены, что хотите удалить сервер {server?.ip}?

+

Это действие нельзя отменить.

+
+
+ + +
+
+
+
+ ); +} + +export default DeleteServerModal; + diff --git a/frontend/src/components/server/EditServerModal.jsx b/frontend/src/components/server/EditServerModal.jsx new file mode 100644 index 0000000..4889b24 --- /dev/null +++ b/frontend/src/components/server/EditServerModal.jsx @@ -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 ( +
+
+
+
+
Редактировать сервер
+ +
+
+
{ e.preventDefault(); onSave && onSave(localServer); }}> +
+ + handleFieldChange('id', e.target.value)} placeholder="srv-1" /> +
+
+ + handleFieldChange('ip', e.target.value)} /> +
+
+ + handleFieldChange('extIp', e.target.value)} placeholder="публичный IP, если отличается" /> +
+
+ + handleFieldChange('internalIp', e.target.value)} placeholder="10.x.x.x / 192.168.x.x" /> +
+
+ + handleFieldChange('dns', e.target.value)} /> +
+
+ + handleFieldChange('country', e.target.value)} /> +
+
+ + handleFieldChange('provider', e.target.value)} /> +
+
+ + +
+
+ + handleFieldChange('tunnel', e.target.value)} /> +
+ {showGateways && ( + <> +
Шлюзы ({currentGateways.length})
+
+ {currentGateways.map((gw, idx) => ( +
+
+
+ + handleGatewayChange(idx, 'name', e.target.value)} + placeholder="GW-MSK" + required + /> +
+
+ + handleGatewayChange(idx, 'ip', e.target.value)} + placeholder="10.0.0.1" + /> +
+
+ + handleGatewayChange(idx, 'comment', e.target.value)} + placeholder="Основной шлюз" + /> +
+
+
+ handlePrimaryChange(idx, e.target.checked)} + title="Основной шлюз" + /> +
+
+
+
+
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
+ {currentGateways.length > 1 && ( + + )} +
+
+ ))} +
+ +
Для jumphost/exit должен быть один основной gateway.
+ + )} +
+
+
+ + +
+
+
+
+ ); +} + +export default EditServerModal; + diff --git a/frontend/src/components/server/LinkGeneratorModal.jsx b/frontend/src/components/server/LinkGeneratorModal.jsx new file mode 100644 index 0000000..da469c6 --- /dev/null +++ b/frontend/src/components/server/LinkGeneratorModal.jsx @@ -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 ( +
+
+
+
+
+ + Генератор ссылок для {server?.ip} +
+ +
+
+
+
+
Настройки URL
+
+ + handleSettingChange('baseUrl', e.target.value)} + placeholder="https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo" + /> +
+
+ + handleSettingChange('cloudflare_gateway', e.target.value)} + placeholder="SWE-IHOR или IP" + /> +
+
+ + handleSettingChange('bunny_gateway', e.target.value)} + placeholder="SWE-IHOR или IP" + /> +
+
+ + handleSettingChange('fastly_gateway', e.target.value)} + placeholder="SWE-IHOR или IP" + /> +
+
+
+
+ + handleSettingChange('telegram_gateway', e.target.value)} + placeholder="IP адрес" + /> +
+
+ + handleSettingChange('hetzner_gateway', e.target.value)} + placeholder="IP адрес" + /> +
+
+ + handleSettingChange('type', e.target.value)} + placeholder="routes" + /> +
+
+ + handleSettingChange('version', e.target.value)} + placeholder="v4.rsc" + /> +
+
+
+ +
+
Сгенерированная ссылка
+
+ + +
+
+
+
+ + +
+
+
+
+ ); +} + +export default LinkGeneratorModal; + diff --git a/frontend/src/components/server/index.js b/frontend/src/components/server/index.js new file mode 100644 index 0000000..11794db --- /dev/null +++ b/frontend/src/components/server/index.js @@ -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'; + diff --git a/frontend/src/hooks/useApiQuery.js b/frontend/src/hooks/useApiQuery.js new file mode 100644 index 0000000..f7370dc --- /dev/null +++ b/frontend/src/hooks/useApiQuery.js @@ -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, + }); +} + diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index f2dce19..f04ec8a 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -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()) }; // Выбираем тип уведомления diff --git a/frontend/src/lib/datetime.js b/frontend/src/lib/datetime.js index 67affa0..2618b73 100644 --- a/frontend/src/lib/datetime.js +++ b/frontend/src/lib/datetime.js @@ -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);