feat: Оптимизация маршрутов для получения данных из S3 с использованием вспомогательных функций getS3JsonWithHeaders и getS3TextWithHeaders для улучшения читаемости и обработки ошибок. Упрощение кода и улучшение обработки конфигураций серверов и фильтров.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s

This commit is contained in:
2025-10-03 02:08:23 +07:00
parent b80bd402a2
commit a4bd1ab1d4
10 changed files with 922 additions and 190 deletions
+302
View File
@@ -0,0 +1,302 @@
/**
* Универсальный хук для управления данными с CRUD операциями
* Устраняет дублирование кода в DomainsNewManager, ASNsNewManager, IPRangesManager
*/
import { useState, useEffect, useRef } from 'react';
import api from '../lib/api.js';
/**
* @param {Object} config - Конфигурация хука
* @param {string} config.apiEndpoint - API endpoint (например '/domains-new')
* @param {string} config.itemKey - Ключ элемента для идентификации (например 'domain', 'asn', 'ipRange')
* @param {function} config.validateItem - Функция валидации элемента (item) => boolean
* @param {function} config.validateCommunity - Функция валидации community (value) => boolean
* @param {function} config.transformResponse - Функция трансформации ответа API
* @param {function} config.transformForSave - Функция трансформации данных перед сохранением
* @param {string} config.saveBodyKey - Ключ для body при сохранении (по умолчанию 'domains')
*/
export function useDataManager(config) {
const {
apiEndpoint,
itemKey,
validateItem,
validateCommunity,
transformResponse = (data) => data,
transformForSave = (data) => data,
saveBodyKey = 'domains',
} = config;
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [sortField, setSortField] = useState(itemKey);
const [sortOrder, setSortOrder] = useState('asc');
const [filterCommunity, setFilterCommunity] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [editingItem, setEditingItem] = useState(null);
const [editingValue, setEditingValue] = useState('');
const didInit = useRef(false);
const pageSize = 10;
// Загрузка данных
const fetchItems = async () => {
setLoading(true);
try {
const response = await api.get(apiEndpoint, { params: { offset: 0, limit: 0, format: 'std' } });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const transformed = transformResponse(payload);
const total = transformed.length;
setItems(transformed);
setOriginalItems(transformed);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
return { success: true };
} catch (error) {
console.error(`Error fetching ${apiEndpoint}:`, error);
return { success: false, error };
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!didInit.current) {
didInit.current = true;
fetchItems();
}
}, []);
// Добавление элемента
const addItem = (newItem) => {
const key = newItem[itemKey];
if (!key) return { success: false, error: 'Invalid item key' };
setItems(prev => [...prev, newItem]);
return { success: true };
};
// Редактирование элемента
const updateItem = (itemKeyValue, updates) => {
setItems(prev => prev.map(item =>
item[itemKey] === itemKeyValue ? { ...item, ...updates } : item
));
return { success: true };
};
// Удаление элемента
const deleteItem = (itemKeyValue) => {
setItems(prev => prev.filter(item => item[itemKey] !== itemKeyValue));
return { success: true };
};
// Дедупликация
const deduplicate = (arr) => {
const seen = new Set();
const out = [];
for (const item of arr) {
const key = String(item[itemKey]).trim().toLowerCase();
if (!key) continue;
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...item, [itemKey]: key });
}
return out;
};
// Вычисление diff
const computeDiff = (before, after) => {
const mapBefore = new Map(before.map(i => [i[itemKey], i]));
const mapAfter = new Map(after.map(i => [i[itemKey], i]));
const added = [];
const removed = [];
const changed = [];
for (const [k, v] of mapAfter) {
if (!mapBefore.has(k)) {
added.push(v);
continue;
}
const prev = mapBefore.get(k);
if (String(prev.community) !== String(v.community)) {
changed.push({ from: prev, to: v });
}
}
for (const [k, v] of mapBefore) {
if (!mapAfter.has(k)) removed.push(v);
}
return { added, removed, changed };
};
// Сохранение изменений
const saveChanges = async () => {
setLoading(true);
try {
// Валидируем и дедуплицируем
const valid = items.filter(item => {
const itemValid = validateItem ? validateItem(item[itemKey]) : true;
const communityValid = validateCommunity ? validateCommunity(item.community) : true;
return itemValid && communityValid;
});
const unique = deduplicate(valid);
// Загружаем полный список, мержим и сохраняем
const fullRes = await api.get(apiEndpoint, { params: { offset: 0, limit: 0, format: 'std' } });
const fullPayload = Array.isArray(fullRes.data?.items) ? fullRes.data.items : [];
const full = transformResponse(fullPayload);
const fullMap = new Map(full.map(i => [String(i[itemKey]).trim().toLowerCase(), i]));
const originalPageMap = new Map(originalItems.map(i => [String(i[itemKey]).trim().toLowerCase(), true]));
const uniqueMap = new Map(unique.map(i => [String(i[itemKey]).trim().toLowerCase(), i]));
// Удаляем то, что было на странице но отсутствует в изменённом наборе
for (const key of originalPageMap.keys()) {
if (!uniqueMap.has(key)) fullMap.delete(key);
}
// Добавляем/обновляем
for (const [key, val] of uniqueMap.entries()) {
fullMap.set(key, val);
}
const fullToSave = Array.from(fullMap.values());
const transformed = transformForSave(fullToSave);
const et = fullRes?.headers?.etag || etag;
const response = await api.post(apiEndpoint, { [saveBodyKey]: transformed, etag: et }, { validateStatus: () => true });
if (response.status === 412) {
return { success: false, error: 'ETag mismatch', code: 412 };
}
if (response.status >= 400) {
return { success: false, error: `Save failed with status ${response.status}` };
}
setEtag(response.headers?.etag || et);
await fetchItems();
return { success: true };
} catch (error) {
console.error('Error saving changes:', error);
return { success: false, error };
} finally {
setLoading(false);
}
};
// Сортировка
const sortedItems = [...items].sort((a, b) => {
let valA = a[sortField] || '';
let valB = b[sortField] || '';
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
// Фильтрация
const filtered = sortedItems.filter(item => {
const byCommunity = !filterCommunity || item.community === filterCommunity;
const term = String(searchTerm || '').toLowerCase();
const bySearch = !term || String(item[itemKey]).toLowerCase().includes(term);
return byCommunity && bySearch;
});
// Пагинация
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// Уникальные community для фильтра
const allCommunities = Array.from(new Set(items.map(i => i.community)));
// Управление сортировкой
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Инлайн редактирование
const startEdit = (item) => {
setEditingItem(item[itemKey]);
setEditingValue(item.community);
};
const saveEdit = () => {
if (!editingItem) return { success: false };
updateItem(editingItem, { community: editingValue.trim() });
setEditingItem(null);
return { success: true };
};
const cancelEdit = () => {
setEditingItem(null);
};
return {
// Состояние
items,
originalItems,
setItems,
loading,
etag,
lastModified,
contentLength,
// Пагинация
currentPage,
setCurrentPage,
totalItems,
totalPages,
pageSize,
paginatedItems,
// Сортировка
sortField,
sortOrder,
handleSort,
// Фильтрация
filterCommunity,
setFilterCommunity,
searchTerm,
setSearchTerm,
filtered,
allCommunities,
// CRUD
fetchItems,
addItem,
updateItem,
deleteItem,
saveChanges,
deduplicate,
computeDiff,
// Инлайн редактирование
editingItem,
editingValue,
setEditingValue,
startEdit,
saveEdit,
cancelEdit,
};
}