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 (
-
-
-
-
-
Добавить новый фильтр
-
-
-
-
-
-
- );
-}
-
-// Модальное окно редактирования фильтра
-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 (
-
-
-
-
-
Редактировать фильтр
-
-
-
-
-
-
- );
-}
-
-// Модальное окно удаления фильтра
-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 (
-
-
-
-
-
Добавить новый сервер
-
-
-
-
-
-
- );
-}
-
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 (
-
-
-
-
-
Редактировать сервер
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-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}
-
-
-
-
-
-
-
-
Сгенерированная ссылка
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-// Модальное окно для добавления нового сервера
-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}
-
- )}
-
-
-
-
-
-
-
-
-
- );
-}
-
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 (
+ <>
+
+
+ {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 (
+
+
+
+
+
Добавить новый фильтр
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
Добавить новый сервер
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
Редактировать фильтр
+
+
+
+
+
+
+ );
+}
+
+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}
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
Редактировать сервер
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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}
+
+
+
+
+
+
+
+
Сгенерированная ссылка
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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);