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
+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('');
});
});
});