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.
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled

This commit is contained in:
2025-12-24 16:02:26 +07:00
parent 27dade3eda
commit 8bd39c554e
13 changed files with 1403 additions and 19 deletions
+14 -4
View File
@@ -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"
@@ -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 = () => (
<thead className="sticky-top bg-white" style={{ zIndex: 1 }}>
<tr>
{selectable && (
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={items.length > 0 && items.every(i => selectedItems.has(i[itemKey]))}
onChange={(e) => e.target.checked ? onSelectAll?.() : onDeselectAll?.()}
title="Выбрать все"
/>
</th>
)}
{columns.map((col) => {
const isSortable = col.sortable !== false && onSort;
const isActive = sortField === col.key;
return (
<th
key={col.key}
className={isSortable ? 'cursor-pointer user-select-none' : ''}
onClick={isSortable ? () => onSort(col.key) : undefined}
style={col.width ? { width: col.width } : undefined}
>
<div className="d-flex align-items-center">
{col.icon && <col.icon size={16} className="me-1 text-muted" />}
{col.title}
{isSortable && isActive && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
);
})}
{hasActions && <th className="text-end">Действия</th>}
</tr>
</thead>
);
// Рендер действий
const renderActions = (item) => (
<>
{onEdit && (
<Tooltip content="Редактировать">
<button
className="btn btn-outline-primary btn-icon btn-sm me-1"
onClick={() => onEdit(item)}
>
<IconEdit size={16} />
</button>
</Tooltip>
)}
{onCopy && (
<Tooltip content="Копировать">
<button
className="btn btn-outline-secondary btn-icon btn-sm me-1"
onClick={() => onCopy(item)}
>
<IconCopy size={16} />
</button>
</Tooltip>
)}
{actions.map((action, idx) => (
<Tooltip key={idx} content={action.label}>
<button
className={`btn btn-outline-${action.variant || 'secondary'} btn-icon btn-sm me-1`}
onClick={() => action.onClick(item)}
>
<action.icon size={16} />
</button>
</Tooltip>
))}
{onDelete && (
<Tooltip content="Удалить">
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => onDelete(item)}
>
<IconTrash size={16} />
</button>
</Tooltip>
)}
</>
);
// Загрузка
if (loading) {
return <TableSkeleton rows={10} cols={columns.length + (hasActions ? 1 : 0)} hasCheckbox={selectable} />;
}
// Пустое состояние
if (items.length === 0) {
return (
<div className="p-4">
{emptyState ? (
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
/>
) : (
<EmptyState title="Нет данных" description="Добавьте записи, чтобы начать." />
)}
</div>
);
}
return (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
{renderHeader()}
</table>
<div
ref={parentRef}
style={{
height: `${Math.min(containerHeight, items.length * rowHeight + 20)}px`,
overflow: 'auto',
}}
>
<div style={{ height: `${totalSize}px`, position: 'relative' }}>
<table className="table card-table table-vcenter table-nowrap mb-0">
<tbody>
{virtualItems.map((virtualRow) => {
const item = items[virtualRow.index];
const id = item[itemKey];
const isSelected = selectedItems.has(id);
return (
<tr
key={id}
className={isSelected ? 'table-selected' : ''}
style={{
height: `${rowHeight}px`,
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{selectable && (
<td style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={isSelected}
onChange={() => onSelectItem?.(id)}
/>
</td>
)}
{columns.map((col) => (
<td key={col.key} className={col.className} style={col.width ? { width: col.width } : undefined}>
{col.render ? col.render(item[col.key], item) : item[col.key]}
</td>
))}
{hasActions && (
<td className="text-end">
{renderActions(item)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="card-footer d-flex align-items-center justify-content-between">
<span className="text-muted">
Показано {items.length} записей
{items.length > 100 && (
<span className="badge bg-blue-lt text-blue ms-2">Виртуализация активна</span>
)}
</span>
</div>
</div>
);
}
export default VirtualizedTable;
+114 -15
View File
@@ -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 });
},
});
+141
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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']);
});
});
});
+122
View File
@@ -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}$/);
});
});
});
+49
View File
@@ -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(''),
},
});
+284
View File
@@ -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<Server> {
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<T> {
data?: T;
ok?: boolean;
etag?: string;
lastModified?: string;
contentLength?: number;
}
export interface ApiError {
message: string;
details?: string;
code?: string | number;
}
export interface PaginatedResponse<T> {
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<T> {
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<T> {
items: T[];
loading: boolean;
error: string | null;
total: number;
currentPage: number;
totalPages: number;
setCurrentPage: (page: number) => void;
refresh: () => Promise<void>;
create: (item: Partial<T>) => Promise<void>;
update: (id: string, item: Partial<T>) => Promise<void>;
remove: (id: string) => Promise<void>;
}
// ================== Query Types ==================
export interface QueryState<T> {
data: T | undefined;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
}
export interface MutationState<TData, TVariables> {
mutate: (variables: TVariables) => void;
mutateAsync: (variables: TVariables) => Promise<TData>;
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[];
}
+81
View File
@@ -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
*/
+110
View File
@@ -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);
});
});
});
+129
View File
@@ -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('');
});
});
});
+48
View File
@@ -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" }]
}
+24
View File
@@ -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"]
}
+34
View File
@@ -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'),
},
},
});