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 (
+
+
+
+
+
+
+
+ Показано {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'),
+ },
+ },
+});
+