From 8bd39c554e3f7d42e91992475a8e31b6d6b034ee Mon Sep 17 00:00:00 2001 From: shats Date: Wed, 24 Dec 2025 16:02:26 +0700 Subject: [PATCH] feat: Enhance API mutation hooks with optimistic updates for domains, IP ranges, ASNs, servers, communities, server filters, and billing, improving user experience by providing immediate feedback on data changes. --- frontend/package.json | 18 +- frontend/src/components/VirtualizedTable.jsx | 253 +++++++++++++++++ frontend/src/hooks/useApiQuery.js | 129 ++++++++- frontend/src/hooks/useApiQuery.test.ts | 141 +++++++++ frontend/src/lib/datetime.test.ts | 122 ++++++++ frontend/src/test/setup.ts | 49 ++++ frontend/src/types/index.ts | 284 +++++++++++++++++++ frontend/src/utils/ipUtils.js | 81 ++++++ frontend/src/utils/ipUtils.test.ts | 110 +++++++ frontend/src/utils/serverUtils.test.ts | 129 +++++++++ frontend/tsconfig.json | 48 ++++ frontend/tsconfig.node.json | 24 ++ frontend/vitest.config.ts | 34 +++ 13 files changed, 1403 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/VirtualizedTable.jsx create mode 100644 frontend/src/hooks/useApiQuery.test.ts create mode 100644 frontend/src/lib/datetime.test.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/utils/ipUtils.test.ts create mode 100644 frontend/src/utils/serverUtils.test.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package.json b/frontend/package.json index d597b72..df5e35a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,15 +5,20 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@tabler/core": "^1.3.2", "@tabler/icons-react": "^3.34.0", - "axios": "^1.10.0", "@tanstack/react-query": "^5.56.2", + "@tanstack/react-virtual": "^3.10.8", + "axios": "^1.10.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1", @@ -21,6 +26,8 @@ }, "devDependencies": { "@eslint/js": "^9.29.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.5.2", @@ -28,7 +35,10 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.2.0", - "vite": "^7.0.0" + "jsdom": "^26.0.0", + "typescript": "^5.7.2", + "vite": "^7.0.0", + "vitest": "^2.1.8" }, "optionalDependencies": { "@rollup/rollup-linux-x64-musl": "4.40.0" diff --git a/frontend/src/components/VirtualizedTable.jsx b/frontend/src/components/VirtualizedTable.jsx new file mode 100644 index 0000000..da252be --- /dev/null +++ b/frontend/src/components/VirtualizedTable.jsx @@ -0,0 +1,253 @@ +import { useRef, useMemo } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react'; +import TableSkeleton from './TableSkeleton.jsx'; +import EmptyState from './EmptyState.jsx'; +import Tooltip from './Tooltip.jsx'; + +/** + * Виртуализированная таблица для больших списков данных (1000+ элементов) + * Рендерит только видимые строки для оптимальной производительности + * + * @param {Object} props + * @param {Array} props.columns - Массив описаний колонок + * @param {Array} props.items - Массив данных для отображения + * @param {string} props.itemKey - Ключ уникального идентификатора + * @param {boolean} props.loading - Состояние загрузки + * @param {string} props.sortField - Текущее поле сортировки + * @param {string} props.sortOrder - Направление сортировки + * @param {function} props.onSort - Обработчик сортировки + * @param {Set} props.selectedItems - Выбранные элементы + * @param {function} props.onSelectItem - Обработчик выбора + * @param {function} props.onSelectAll - Обработчик выбора всех + * @param {function} props.onEdit - Обработчик редактирования + * @param {function} props.onDelete - Обработчик удаления + * @param {function} props.onCopy - Обработчик копирования + * @param {number} props.rowHeight - Высота строки в пикселях (default: 48) + * @param {number} props.containerHeight - Высота контейнера (default: 600) + * @param {number} props.overscan - Количество дополнительных строк (default: 5) + * @param {Object} props.emptyState - Настройки пустого состояния + * @param {boolean} props.selectable - Включить чекбоксы (default: true) + */ +function VirtualizedTable({ + columns = [], + items = [], + itemKey = 'id', + loading = false, + sortField, + sortOrder = 'asc', + onSort, + selectedItems = new Set(), + onSelectItem, + onSelectAll, + onDeselectAll, + onEdit, + onDelete, + onCopy, + rowHeight = 48, + containerHeight = 600, + overscan = 5, + emptyState, + selectable = true, + actions = [], +}) { + const parentRef = useRef(null); + const hasActions = onEdit || onDelete || onCopy || actions.length > 0; + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => rowHeight, + overscan, + }); + + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + + // Рендер заголовка + const renderHeader = () => ( + + + {selectable && ( + + 0 && items.every(i => selectedItems.has(i[itemKey]))} + onChange={(e) => e.target.checked ? onSelectAll?.() : onDeselectAll?.()} + title="Выбрать все" + /> + + )} + {columns.map((col) => { + const isSortable = col.sortable !== false && onSort; + const isActive = sortField === col.key; + + return ( + onSort(col.key) : undefined} + style={col.width ? { width: col.width } : undefined} + > +
+ {col.icon && } + {col.title} + {isSortable && isActive && ( + + {sortOrder === 'asc' ? : } + + )} +
+ + ); + })} + {hasActions && Действия} + + + ); + + // Рендер действий + const renderActions = (item) => ( + <> + {onEdit && ( + + + + )} + {onCopy && ( + + + + )} + {actions.map((action, idx) => ( + + + + ))} + {onDelete && ( + + + + )} + + ); + + // Загрузка + if (loading) { + return ; + } + + // Пустое состояние + if (items.length === 0) { + return ( +
+ {emptyState ? ( + + ) : ( + + )} +
+ ); + } + + return ( +
+ + {renderHeader()} +
+ +
+
+ + + {virtualItems.map((virtualRow) => { + const item = items[virtualRow.index]; + const id = item[itemKey]; + const isSelected = selectedItems.has(id); + + return ( + + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + {hasActions && ( + + )} + + ); + })} + +
+ onSelectItem?.(id)} + /> + + {col.render ? col.render(item[col.key], item) : item[col.key]} + + {renderActions(item)} +
+
+
+ +
+ + Показано {items.length} записей + {items.length > 100 && ( + Виртуализация активна + )} + +
+
+ ); +} + +export default VirtualizedTable; + diff --git a/frontend/src/hooks/useApiQuery.js b/frontend/src/hooks/useApiQuery.js index f7370dc..ccc439d 100644 --- a/frontend/src/hooks/useApiQuery.js +++ b/frontend/src/hooks/useApiQuery.js @@ -48,7 +48,7 @@ export function useDomains(options = {}) { } /** - * Мутация для сохранения доменов + * Мутация для сохранения доменов с оптимистичным обновлением */ export function useSaveDomains() { const queryClient = useQueryClient(); @@ -64,7 +64,26 @@ export function useSaveDomains() { } return res.data; }, - onSuccess: () => { + // Оптимистичное обновление + onMutate: async ({ domains }) => { + // Отменяем текущие запросы + await queryClient.cancelQueries({ queryKey: queryKeys.domains }); + // Сохраняем предыдущие данные + const previousData = queryClient.getQueryData(queryKeys.domains); + // Оптимистично обновляем кэш + queryClient.setQueryData(queryKeys.domains, (old) => ({ + ...old, + items: domains, + })); + return { previousData }; + }, + onError: (err, variables, context) => { + // Откатываем при ошибке + if (context?.previousData) { + queryClient.setQueryData(queryKeys.domains, context.previousData); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.domains }); }, }); @@ -90,7 +109,7 @@ export function useIpRanges(options = {}) { } /** - * Мутация для сохранения IP-диапазонов + * Мутация для сохранения IP-диапазонов с оптимистичным обновлением */ export function useSaveIpRanges() { const queryClient = useQueryClient(); @@ -106,7 +125,21 @@ export function useSaveIpRanges() { } return res.data; }, - onSuccess: () => { + onMutate: async ({ ipRanges }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.ipRanges }); + const previousData = queryClient.getQueryData(queryKeys.ipRanges); + queryClient.setQueryData(queryKeys.ipRanges, (old) => ({ + ...old, + items: ipRanges, + })); + return { previousData }; + }, + onError: (err, variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKeys.ipRanges, context.previousData); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.ipRanges }); }, }); @@ -138,14 +171,13 @@ export function useAsns(options = {}) { } /** - * Мутация для сохранения ASN + * Мутация для сохранения 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) { @@ -156,7 +188,21 @@ export function useSaveAsns() { } return res.data; }, - onSuccess: () => { + onMutate: async ({ asns }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.asns }); + const previousData = queryClient.getQueryData(queryKeys.asns); + queryClient.setQueryData(queryKeys.asns, (old) => ({ + ...old, + items: asns, + })); + return { previousData }; + }, + onError: (err, variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKeys.asns, context.previousData); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.asns }); }, }); @@ -181,7 +227,7 @@ export function useServers(options = {}) { } /** - * Мутация для сохранения серверов + * Мутация для сохранения серверов с оптимистичным обновлением */ export function useSaveServers() { const queryClient = useQueryClient(); @@ -197,7 +243,21 @@ export function useSaveServers() { } return res.data; }, - onSuccess: () => { + onMutate: async ({ servers }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.servers }); + const previousData = queryClient.getQueryData(queryKeys.servers); + queryClient.setQueryData(queryKeys.servers, (old) => ({ + ...old, + items: servers, + })); + return { previousData }; + }, + onError: (err, variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKeys.servers, context.previousData); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.servers }); }, }); @@ -219,7 +279,7 @@ export function useCommunities(options = {}) { } /** - * Мутация для сохранения community справочника + * Мутация для сохранения community справочника с оптимистичным обновлением */ export function useSaveCommunities() { const queryClient = useQueryClient(); @@ -229,7 +289,18 @@ export function useSaveCommunities() { const res = await api.post('/communities', { communities }); return res.data; }, - onSuccess: () => { + onMutate: async ({ communities }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.communities }); + const previousData = queryClient.getQueryData(queryKeys.communities); + queryClient.setQueryData(queryKeys.communities, communities); + return { previousData }; + }, + onError: (err, variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKeys.communities, context.previousData); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.communities }); }, }); @@ -255,7 +326,7 @@ export function useServerFilters(serverId, options = {}) { } /** - * Мутация для сохранения фильтров сервера + * Мутация для сохранения фильтров сервера с оптимистичным обновлением */ export function useSaveServerFilters(serverId) { const queryClient = useQueryClient(); @@ -271,7 +342,21 @@ export function useSaveServerFilters(serverId) { } return res.data; }, - onSuccess: () => { + onMutate: async ({ filters }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.serverFilters(serverId) }); + const previousData = queryClient.getQueryData(queryKeys.serverFilters(serverId)); + queryClient.setQueryData(queryKeys.serverFilters(serverId), (old) => ({ + ...old, + filters, + })); + return { previousData }; + }, + onError: (err, variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKeys.serverFilters(serverId), context.previousData); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.serverFilters(serverId) }); }, }); @@ -326,7 +411,7 @@ export function useBilling(options = {}) { } /** - * Мутация для сохранения биллинга + * Мутация для сохранения биллинга с оптимистичным обновлением */ export function useSaveBilling() { const queryClient = useQueryClient(); @@ -342,7 +427,21 @@ export function useSaveBilling() { } return res.data; }, - onSuccess: () => { + onMutate: async ({ items }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.billing }); + const previousData = queryClient.getQueryData(queryKeys.billing); + queryClient.setQueryData(queryKeys.billing, (old) => ({ + ...old, + items, + })); + return { previousData }; + }, + onError: (err, variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKeys.billing, context.previousData); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.billing }); }, }); diff --git a/frontend/src/hooks/useApiQuery.test.ts b/frontend/src/hooks/useApiQuery.test.ts new file mode 100644 index 0000000..436e905 --- /dev/null +++ b/frontend/src/hooks/useApiQuery.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { + useDomains, + useIpRanges, + useAsns, + useServers, + useCommunities, + queryKeys, +} from './useApiQuery'; + +// Мокаем api +vi.mock('../lib/api', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +import api from '../lib/api'; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + React.createElement(QueryClientProvider, { client: queryClient }, children) + ); +}; + +describe('useApiQuery hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('useDomains', () => { + it('загружает домены успешно', async () => { + const mockDomains = [ + { domain: 'example.com', type: '120' }, + { domain: 'test.com', type: '130' }, + ]; + + (api.get as ReturnType).mockResolvedValueOnce({ + data: { items: mockDomains }, + headers: { etag: 'abc123', 'last-modified': '2024-01-01' }, + }); + + const { result } = renderHook(() => useDomains(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.items).toEqual(mockDomains); + expect(result.current.data?.etag).toBe('abc123'); + }); + + it('обрабатывает пустой ответ', async () => { + (api.get as ReturnType).mockResolvedValueOnce({ + data: { items: null }, + headers: {}, + }); + + const { result } = renderHook(() => useDomains(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.items).toEqual([]); + }); + }); + + describe('useServers', () => { + it('загружает серверы успешно', async () => { + const mockServers = [ + { id: '1', ip: '192.168.1.1', dns: 'srv1.example.com' }, + { id: '2', ip: '192.168.1.2', dns: 'srv2.example.com' }, + ]; + + (api.get as ReturnType).mockResolvedValueOnce({ + data: mockServers, + headers: { etag: 'xyz789' }, + }); + + const { result } = renderHook(() => useServers(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.items).toEqual(mockServers); + }); + }); + + describe('useCommunities', () => { + it('загружает справочник communities', async () => { + const mockCommunities = [ + { value: '120', name: 'Cloudflare' }, + { value: '130', name: 'Telegram' }, + ]; + + (api.get as ReturnType).mockResolvedValueOnce({ + data: mockCommunities, + }); + + const { result } = renderHook(() => useCommunities(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockCommunities); + }); + }); + + describe('queryKeys', () => { + it('генерирует правильные ключи', () => { + expect(queryKeys.domains).toEqual(['domains']); + expect(queryKeys.servers).toEqual(['servers']); + expect(queryKeys.serverFilters('srv-1')).toEqual(['serverFilters', 'srv-1']); + }); + }); +}); + diff --git a/frontend/src/lib/datetime.test.ts b/frontend/src/lib/datetime.test.ts new file mode 100644 index 0000000..f410607 --- /dev/null +++ b/frontend/src/lib/datetime.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + formatDateTime, + formatDateTimeShort, + formatTime, + formatRelative, + formatDateTimeWithRelative, + now, +} from './datetime'; + +describe('datetime utilities', () => { + beforeEach(() => { + // Фиксируем время для предсказуемых тестов + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-12-24T15:30:45.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('formatDateTime', () => { + it('форматирует Date объект', () => { + const date = new Date('2024-12-24T15:30:45.000Z'); + // Результат зависит от часового пояса, проверяем формат + const result = formatDateTime(date); + expect(result).toMatch(/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}:\d{2}$/); + }); + + it('форматирует строку даты', () => { + const result = formatDateTime('2024-12-24T15:30:45.000Z'); + expect(result).toMatch(/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}:\d{2}$/); + }); + + it('возвращает пустую строку для null/undefined', () => { + expect(formatDateTime(null)).toBe(''); + expect(formatDateTime(undefined)).toBe(''); + expect(formatDateTime('')).toBe(''); + }); + + it('возвращает пустую строку для невалидной даты', () => { + expect(formatDateTime('invalid-date')).toBe(''); + }); + }); + + describe('formatDateTimeShort', () => { + it('форматирует без секунд', () => { + const date = new Date('2024-12-24T15:30:45.000Z'); + const result = formatDateTimeShort(date); + expect(result).toMatch(/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}$/); + expect(result).not.toContain(':45'); + }); + + it('возвращает пустую строку для null', () => { + expect(formatDateTimeShort(null)).toBe(''); + }); + }); + + describe('formatTime', () => { + it('возвращает только время', () => { + const date = new Date('2024-12-24T15:30:45.000Z'); + const result = formatTime(date); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + it('возвращает пустую строку для null', () => { + expect(formatTime(null)).toBe(''); + }); + }); + + describe('formatRelative', () => { + it('возвращает "только что" для недавних дат', () => { + const now = new Date(); + expect(formatRelative(now)).toBe('только что'); + }); + + it('возвращает секунды', () => { + const date = new Date(Date.now() - 30 * 1000); // 30 секунд назад + expect(formatRelative(date)).toBe('30 сек назад'); + }); + + it('возвращает минуты', () => { + const date = new Date(Date.now() - 5 * 60 * 1000); // 5 минут назад + expect(formatRelative(date)).toBe('5 мин назад'); + }); + + it('возвращает часы', () => { + const date = new Date(Date.now() - 3 * 60 * 60 * 1000); // 3 часа назад + expect(formatRelative(date)).toBe('3 ч назад'); + }); + + it('возвращает дни', () => { + const date = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // 2 дня назад + expect(formatRelative(date)).toBe('2 дн назад'); + }); + + it('возвращает пустую строку для null', () => { + expect(formatRelative(null)).toBe(''); + }); + }); + + describe('formatDateTimeWithRelative', () => { + it('комбинирует абсолютное и относительное время', () => { + const date = new Date(Date.now() - 5 * 60 * 1000); // 5 минут назад + const result = formatDateTimeWithRelative(date); + expect(result).toContain('(5 мин назад)'); + expect(result).toMatch(/^\d{2}\.\d{2}\.\d{4}/); + }); + + it('возвращает пустую строку для null', () => { + expect(formatDateTimeWithRelative(null)).toBe(''); + }); + }); + + describe('now', () => { + it('возвращает текущую дату в правильном формате', () => { + const result = now(); + expect(result).toMatch(/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}:\d{2}$/); + }); + }); +}); + diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..06a650a --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,49 @@ +import '@testing-library/jest-dom'; +import { afterEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +// Очистка после каждого теста +afterEach(() => { + cleanup(); +}); + +// Мок для localStorage +const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +}; +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); + +// Мок для matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Мок для ResizeObserver +class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} +Object.defineProperty(window, 'ResizeObserver', { value: ResizeObserverMock }); + +// Мок для clipboard +Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: vi.fn().mockResolvedValue(undefined), + readText: vi.fn().mockResolvedValue(''), + }, +}); + diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..cb84cb9 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,284 @@ +/** + * Базовые типы для проекта Router Lists UI + */ + +// ================== Server Types ================== + +export interface Gateway { + id: string; + name: string; + ip: string; + comment?: string; + primary: boolean; +} + +export interface Server { + id: string; + ip: string; + extIp?: string; + internalIp?: string; + dns: string; + country: string; + provider: string; + tunnel: TunnelType; + type: ServerType; + gateway?: string; + gateways: Gateway[]; +} + +export type ServerType = 'jumphost' | 'exit'; +export type TunnelType = 'GRE' | 'IPSec' | 'WireGuard' | 'OpenVPN'; + +export interface ServerFormData extends Partial { + customProvider?: string; +} + +// ================== Filter Types ================== + +export interface Filter { + id?: string; + community: string; + gateway: string; + description?: string; + enabled?: boolean; +} + +export interface ServerFilters { + serverId: string; + filters: Filter[]; + etag?: string; +} + +// ================== Community Types ================== + +export interface Community { + value: string; + name?: string; + description?: string; + gatewayDefault?: string; + category?: string; +} + +// ================== Domain Types ================== + +export interface Domain { + domain: string; + type: string; // community value + comment?: string; +} + +export interface DomainFormData { + domain: string; + community: string; + comment?: string; +} + +// ================== IP Range Types ================== + +export interface IpRange { + ip: string; + type: string; // community value + comment?: string; +} + +export interface IpRangeFormData { + ip: string; + community: string; + comment?: string; +} + +// ================== ASN Types ================== + +export interface Asn { + asn: string; + community: string; + comment?: string; +} + +// ================== Billing Types ================== + +export interface BillingItem { + id: string; + serverId: string; + provider: string; + cost: number; + currency: string; + period: 'monthly' | 'yearly'; + startDate?: string; + endDate?: string; + notes?: string; +} + +// ================== API Types ================== + +export interface ApiResponse { + data?: T; + ok?: boolean; + etag?: string; + lastModified?: string; + contentLength?: number; +} + +export interface ApiError { + message: string; + details?: string; + code?: string | number; +} + +export interface PaginatedResponse { + items: T[]; + total: number; + offset?: number; + limit?: number; +} + +export interface S3Meta { + etag: string; + lastModified: string; + contentLength?: number; +} + +export interface S3LastModified { + domainsNew?: S3Meta; + asns?: S3Meta; + servers?: S3Meta; + filters?: S3Meta; + ipRanges?: S3Meta; + communities?: S3Meta; +} + +// ================== UI Types ================== + +export type SortOrder = 'asc' | 'desc'; + +export interface TableColumn { + key: keyof T | string; + title: string; + sortable?: boolean; + width?: string; + className?: string; + icon?: React.ComponentType<{ size?: number; className?: string }>; + render?: (value: unknown, item: T) => React.ReactNode; +} + +export interface PaginationProps { + currentPage: number; + totalPages: number; + totalItems: number; + pageSize: number; + onPageChange: (page: number) => void; +} + +export interface ModalProps { + show: boolean; + onClose: () => void; +} + +export interface ConfirmDialogProps extends ModalProps { + title: string; + message: string; + onConfirm: () => void; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'warning' | 'info'; +} + +// ================== Form Types ================== + +export interface FormFieldProps { + name: string; + label?: string; + value: string | number | boolean; + onChange: (value: string | number | boolean) => void; + error?: string; + required?: boolean; + placeholder?: string; + type?: 'text' | 'number' | 'email' | 'password' | 'textarea' | 'select' | 'checkbox'; + options?: { value: string; label: string }[]; + disabled?: boolean; + helpText?: string; +} + +// ================== Hook Types ================== + +export interface UseDataManagerOptions { + autoFetch?: boolean; + pageSize?: number; +} + +export interface UseDataManagerReturn { + items: T[]; + loading: boolean; + error: string | null; + total: number; + currentPage: number; + totalPages: number; + setCurrentPage: (page: number) => void; + refresh: () => Promise; + create: (item: Partial) => Promise; + update: (id: string, item: Partial) => Promise; + remove: (id: string) => Promise; +} + +// ================== Query Types ================== + +export interface QueryState { + data: T | undefined; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; +} + +export interface MutationState { + mutate: (variables: TVariables) => void; + mutateAsync: (variables: TVariables) => Promise; + isPending: boolean; + isError: boolean; + error: Error | null; +} + +// ================== Network Status Types ================== + +export interface NetworkStatus { + [serverId: string]: { + available: boolean; + latency?: number; + lastCheck?: string; + }; +} + +// ================== Connection Types (for Graph) ================== + +export interface ServerConnection { + id: string; + from: string; + to: string; + tunnelType: TunnelType; + ipA?: string; + ipB?: string; +} + +// ================== URL Settings Types ================== + +export interface UrlSettings { + baseUrl: string; + cloudflare_gateway: string; + bunny_gateway: string; + fastly_gateway: string; + telegram_gateway: string; + hetzner_gateway: string; + type: string; + version: string; +} + +// ================== Export Utils ================== + +export type ExportFormat = 'json' | 'csv' | 'txt'; + +export interface ExportOptions { + format: ExportFormat; + filename?: string; + fields?: string[]; +} + diff --git a/frontend/src/utils/ipUtils.js b/frontend/src/utils/ipUtils.js index b207353..0e21966 100644 --- a/frontend/src/utils/ipUtils.js +++ b/frontend/src/utils/ipUtils.js @@ -3,6 +3,87 @@ * Вынесено из IPRangesManager для переиспользования */ +// Регулярные выражения для валидации +const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +const IPV6_REGEX = /^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(?:ffff(?::0{1,4})?:)?(?:(?:25[0-5]|(?:2[0-4]|1?[0-9])?[0-9])\.){3}(?:25[0-5]|(?:2[0-4]|1?[0-9])?[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1?[0-9])?[0-9])\.){3}(?:25[0-5]|(?:2[0-4]|1?[0-9])?[0-9]))$/; + +/** + * Проверка валидности IPv4 адреса + */ +export function isValidIPv4(ip) { + if (!ip || typeof ip !== 'string') return false; + return IPV4_REGEX.test(ip.trim()); +} + +/** + * Проверка валидности IPv6 адреса + */ +export function isValidIPv6(ip) { + if (!ip || typeof ip !== 'string') return false; + return IPV6_REGEX.test(ip.trim()); +} + +/** + * Проверка валидности IP адреса (v4 или v6) + */ +export function isValidIP(ip) { + return isValidIPv4(ip) || isValidIPv6(ip); +} + +/** + * Проверка валидности CIDR нотации + */ +export function isValidCIDR(cidr) { + if (!cidr || typeof cidr !== 'string') return false; + const parts = cidr.trim().split('/'); + if (parts.length !== 2) return false; + + const [ip, maskStr] = parts; + const mask = parseInt(maskStr, 10); + + if (isValidIPv4(ip)) { + return mask >= 0 && mask <= 32; + } + if (isValidIPv6(ip)) { + return mask >= 0 && mask <= 128; + } + return false; +} + +/** + * Проверка валидности IP или CIDR + */ +export function isValidIPRange(input) { + if (!input) return false; + return isValidIP(input) || isValidCIDR(input); +} + +/** + * Нормализация IPv4 адреса (удаление ведущих нулей) + */ +export function normalizeIP(ip) { + if (!isValidIPv4(ip)) return ip; + return ip.split('.').map(octet => parseInt(octet, 10)).join('.'); +} + +/** + * Конвертация IPv4 в число + */ +export function ipToNumber(ip) { + if (!isValidIPv4(ip)) return 0; + const octets = ip.split('.').map(Number); + return (octets[0] << 24) + (octets[1] << 16) + (octets[2] << 8) + octets[3] >>> 0; +} + +/** + * Сравнение двух IP адресов + */ +export function compareIPs(a, b) { + const numA = ipToNumber(a); + const numB = ipToNumber(b); + return numA - numB; +} + /** * Парсинг маски из CIDR */ diff --git a/frontend/src/utils/ipUtils.test.ts b/frontend/src/utils/ipUtils.test.ts new file mode 100644 index 0000000..22a5876 --- /dev/null +++ b/frontend/src/utils/ipUtils.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { + isValidIPv4, + isValidIPv6, + isValidIP, + isValidCIDR, + isValidIPRange, + normalizeIP, + ipToNumber, + compareIPs, +} from './ipUtils'; + +describe('ipUtils', () => { + describe('isValidIPv4', () => { + it('валидирует корректные IPv4 адреса', () => { + expect(isValidIPv4('192.168.1.1')).toBe(true); + expect(isValidIPv4('10.0.0.0')).toBe(true); + expect(isValidIPv4('255.255.255.255')).toBe(true); + expect(isValidIPv4('0.0.0.0')).toBe(true); + }); + + it('отклоняет некорректные IPv4 адреса', () => { + expect(isValidIPv4('256.1.1.1')).toBe(false); + expect(isValidIPv4('1.1.1')).toBe(false); + expect(isValidIPv4('1.1.1.1.1')).toBe(false); + expect(isValidIPv4('abc.def.ghi.jkl')).toBe(false); + expect(isValidIPv4('')).toBe(false); + }); + }); + + describe('isValidIPv6', () => { + it('валидирует корректные IPv6 адреса', () => { + expect(isValidIPv6('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true); + expect(isValidIPv6('2001:db8:85a3::8a2e:370:7334')).toBe(true); + expect(isValidIPv6('::1')).toBe(true); + expect(isValidIPv6('::')).toBe(true); + expect(isValidIPv6('fe80::1')).toBe(true); + }); + + it('отклоняет некорректные IPv6 адреса', () => { + expect(isValidIPv6('gggg::1')).toBe(false); + expect(isValidIPv6('2001:db8')).toBe(false); + expect(isValidIPv6('')).toBe(false); + }); + }); + + describe('isValidIP', () => { + it('валидирует и IPv4 и IPv6', () => { + expect(isValidIP('192.168.1.1')).toBe(true); + expect(isValidIP('::1')).toBe(true); + expect(isValidIP('invalid')).toBe(false); + }); + }); + + describe('isValidCIDR', () => { + it('валидирует корректные CIDR нотации', () => { + expect(isValidCIDR('192.168.1.0/24')).toBe(true); + expect(isValidCIDR('10.0.0.0/8')).toBe(true); + expect(isValidCIDR('2001:db8::/32')).toBe(true); + }); + + it('отклоняет некорректные CIDR нотации', () => { + expect(isValidCIDR('192.168.1.0/33')).toBe(false); + expect(isValidCIDR('192.168.1.0')).toBe(false); + expect(isValidCIDR('invalid/24')).toBe(false); + }); + }); + + describe('isValidIPRange', () => { + it('валидирует IP адреса и CIDR', () => { + expect(isValidIPRange('192.168.1.1')).toBe(true); + expect(isValidIPRange('192.168.1.0/24')).toBe(true); + }); + + it('отклоняет некорректный ввод', () => { + expect(isValidIPRange('invalid')).toBe(false); + }); + }); + + describe('normalizeIP', () => { + it('нормализует IPv4', () => { + expect(normalizeIP('192.168.001.001')).toBe('192.168.1.1'); + }); + + it('возвращает как есть при ошибке', () => { + expect(normalizeIP('invalid')).toBe('invalid'); + }); + }); + + describe('ipToNumber', () => { + it('конвертирует IPv4 в число', () => { + expect(ipToNumber('0.0.0.1')).toBe(1); + expect(ipToNumber('0.0.1.0')).toBe(256); + expect(ipToNumber('192.168.1.1')).toBe(3232235777); + }); + + it('возвращает 0 для невалидного IP', () => { + expect(ipToNumber('invalid')).toBe(0); + }); + }); + + describe('compareIPs', () => { + it('сравнивает IP адреса', () => { + expect(compareIPs('192.168.1.1', '192.168.1.2')).toBeLessThan(0); + expect(compareIPs('192.168.1.2', '192.168.1.1')).toBeGreaterThan(0); + expect(compareIPs('192.168.1.1', '192.168.1.1')).toBe(0); + }); + }); +}); + diff --git a/frontend/src/utils/serverUtils.test.ts b/frontend/src/utils/serverUtils.test.ts new file mode 100644 index 0000000..5b450dd --- /dev/null +++ b/frontend/src/utils/serverUtils.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { + makeGateway, + normalizeGateways, + countryToFlag, + needsGateways, + SERVER_TYPE_OPTIONS, + normalizeCommunityValue, + getShortCommunityValue, +} from './serverUtils'; + +describe('serverUtils', () => { + describe('makeGateway', () => { + it('создаёт gateway с дефолтными значениями', () => { + const gw = makeGateway(); + expect(gw).toHaveProperty('id'); + expect(gw.name).toBe(''); + expect(gw.ip).toBe(''); + expect(gw.comment).toBe(''); + expect(gw.primary).toBe(false); + }); + + it('создаёт gateway с переопределёнными значениями', () => { + const gw = makeGateway({ name: 'Test', primary: true }); + expect(gw.name).toBe('Test'); + expect(gw.primary).toBe(true); + }); + + it('генерирует уникальные id', () => { + const gw1 = makeGateway(); + const gw2 = makeGateway(); + expect(gw1.id).not.toBe(gw2.id); + }); + }); + + describe('normalizeGateways', () => { + it('возвращает пустой массив для undefined', () => { + expect(normalizeGateways(undefined, '')).toEqual([]); + }); + + it('возвращает массив как есть, если валидный', () => { + const gateways = [{ id: '1', name: 'GW1', ip: '10.0.0.1', primary: true }]; + const result = normalizeGateways(gateways, ''); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('GW1'); + }); + + it('добавляет fallbackName если name пустое', () => { + const gateways = [{ id: '1', name: '', ip: '10.0.0.1', primary: true }]; + const result = normalizeGateways(gateways, 'Fallback'); + expect(result[0].name).toBe('Fallback'); + }); + }); + + describe('countryToFlag', () => { + it('конвертирует код страны в эмодзи флаг', () => { + expect(countryToFlag('RU')).toBe('🇷🇺'); + expect(countryToFlag('US')).toBe('🇺🇸'); + expect(countryToFlag('DE')).toBe('🇩🇪'); + }); + + it('обрабатывает нижний регистр', () => { + expect(countryToFlag('ru')).toBe('🇷🇺'); + }); + + it('возвращает пустую строку для пустого ввода', () => { + expect(countryToFlag('')).toBe(''); + expect(countryToFlag(null as any)).toBe(''); + }); + }); + + describe('needsGateways', () => { + it('возвращает true для jumphost', () => { + expect(needsGateways('jumphost')).toBe(true); + }); + + it('возвращает true для exit', () => { + expect(needsGateways('exit')).toBe(true); + }); + + it('возвращает false для других типов', () => { + expect(needsGateways('unknown')).toBe(false); + expect(needsGateways('')).toBe(false); + }); + + it('работает независимо от регистра', () => { + expect(needsGateways('JUMPHOST')).toBe(true); + expect(needsGateways('Exit')).toBe(true); + }); + }); + + describe('SERVER_TYPE_OPTIONS', () => { + it('содержит jumphost и exit', () => { + expect(SERVER_TYPE_OPTIONS).toContainEqual({ value: 'jumphost', label: 'Jumphost' }); + expect(SERVER_TYPE_OPTIONS).toContainEqual({ value: 'exit', label: 'Выходная нода' }); + }); + }); + + describe('normalizeCommunityValue', () => { + it('добавляет baseAS если нет двоеточия', () => { + expect(normalizeCommunityValue('120', '65001')).toBe('65001:120'); + }); + + it('оставляет как есть если уже есть двоеточие', () => { + expect(normalizeCommunityValue('65000:120', '65001')).toBe('65000:120'); + }); + + it('возвращает пустую строку для пустого ввода', () => { + expect(normalizeCommunityValue('', '65001')).toBe(''); + expect(normalizeCommunityValue(' ', '65001')).toBe(''); + }); + }); + + describe('getShortCommunityValue', () => { + it('извлекает часть после двоеточия', () => { + expect(getShortCommunityValue('65001:120')).toBe('120'); + }); + + it('возвращает как есть если нет двоеточия', () => { + expect(getShortCommunityValue('120')).toBe('120'); + }); + + it('возвращает пустую строку для пустого ввода', () => { + expect(getShortCommunityValue('')).toBe(''); + expect(getShortCommunityValue(' ')).toBe(''); + }); + }); +}); + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8c4913a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,48 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + /* Allow JS */ + "allowJs": true, + "checkJs": false, + + /* Paths */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@components/*": ["src/components/*"], + "@hooks/*": ["src/hooks/*"], + "@lib/*": ["src/lib/*"], + "@utils/*": ["src/utils/*"] + }, + + /* Interop */ + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "./tsconfig.node.json" }] +} + diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..5a45468 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} + diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..c6663cd --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.{js,jsx,ts,tsx}'], + exclude: [ + 'src/test/**', + 'src/**/*.d.ts', + 'src/main.jsx', + 'src/types/**', + ], + }, + }, + resolve: { + alias: { + '@': resolve(__dirname, './src'), + '@components': resolve(__dirname, './src/components'), + '@hooks': resolve(__dirname, './src/hooks'), + '@lib': resolve(__dirname, './src/lib'), + '@utils': resolve(__dirname, './src/utils'), + }, + }, +}); +