feat: Refactor FilterManager to streamline gateway selection and remove inline editing, enhancing user experience and simplifying filter management.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s

This commit is contained in:
2025-12-07 02:08:02 +07:00
parent 53bf828797
commit a8c6307123
+171 -137
View File
@@ -140,9 +140,7 @@ function FilterManager() {
const [serverListModalOpen, setServerListModalOpen] = useState(false);
const [serverSearch, setServerSearch] = useState('');
// Состояние для инлайн-редактирования и массовых действий
const [editingKey, setEditingKey] = useState(null);
const [editingDraft, setEditingDraft] = useState({ community: '', gateway: '', description: '' });
// Состояние для массовых действий
const [selectedFilterKeys, setSelectedFilterKeys] = useState(new Set());
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
@@ -151,6 +149,39 @@ function FilterManager() {
// Inventory servers (from /servers endpoint)
const [inventoryServers, setInventoryServers] = useState([]);
// Собираем все gateways из всех серверов для выбора
const allGateways = (() => {
const gws = [];
inventoryServers.forEach(srv => {
if (Array.isArray(srv.gateways)) {
srv.gateways.forEach(gw => {
if (gw.name && !gws.some(g => g.name === gw.name)) {
gws.push({
name: gw.name,
ip: gw.ip || '',
comment: gw.comment || '',
serverDns: srv.dns || srv.hostName || '',
country: srv.country || '',
primary: gw.primary
});
}
});
}
// Также добавляем старое поле gateway если есть
if (srv.gateway && !gws.some(g => g.name === srv.gateway)) {
gws.push({
name: srv.gateway,
ip: '',
comment: 'Legacy gateway',
serverDns: srv.dns || srv.hostName || '',
country: srv.country || '',
primary: true
});
}
});
return gws.sort((a, b) => a.name.localeCompare(b.name));
})();
useEffect(() => {
(async () => {
try {
@@ -713,44 +744,6 @@ function FilterManager() {
// Убрано сохранение режима в localStorage по просьбе пользователя
const startInlineEdit = (filter) => {
if (!filter) return;
setEditingKey(filterKey(filter));
setEditingDraft({
community: filter.community || '',
gateway: filter.gateway || '',
description: filter.description || ''
});
};
const cancelInlineEdit = () => {
setEditingKey(null);
setEditingDraft({ community: '', gateway: '', description: '' });
};
const saveInlineEdit = async () => {
if (!editingDraft.community.trim() || !editingDraft.gateway.trim()) {
setError('Поля Community и Gateway должны быть заполнены.');
return;
}
const updated = serverFilters.map((f) =>
filterKey(f) === editingKey
? { community: editingDraft.community.trim(), gateway: editingDraft.gateway.trim(), description: editingDraft.description }
: f
);
setServerFilters(updated);
cancelInlineEdit();
setSelectedFilterKeys(new Set());
try {
await api.post(`/server-filters/${selectedServer.id}`, { filters: updated });
setSuccess('Фильтр успешно обновлен!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error saving server filters:', error);
setError('Не удалось сохранить фильтр.');
}
};
const toggleSelectFilter = (key) => {
setSelectedFilterKeys((prev) => {
const next = new Set(prev);
@@ -1250,10 +1243,9 @@ function FilterManager() {
<tbody>
{processedFilters.filter(f => f != null).map((filter, index) => {
const key = filterKey(filter);
const isEditing = editingKey === key;
const isSelected = selectedFilterKeys.has(key);
return (
<tr key={`${filter?.community || ''}-${index}`} className={isEditing ? 'table-info' : ''}>
<tr key={`${filter?.community || ''}-${index}`}>
<td>
<input
className="form-check-input m-0 align-middle"
@@ -1264,87 +1256,49 @@ function FilterManager() {
/>
</td>
<td>
{isEditing ? (
<input
className="form-control form-control-sm"
value={editingDraft.community}
onChange={(e) => setEditingDraft({ ...editingDraft, community: e.target.value })}
autoFocus
/>
) : (
<div className="d-flex align-items-center">
<span className="badge bg-blue-lt text-blue me-2">
<IconHash size={12} />
</span>
<code className="text-blue">{filter?.community || ''}</code>
</div>
)}
<div className="d-flex align-items-center">
<span className="badge bg-blue-lt text-blue me-2">
<IconHash size={12} />
</span>
<code className="text-blue">{filter?.community || ''}</code>
</div>
</td>
<td>
{isEditing ? (
<input
className="form-control form-control-sm"
value={editingDraft.gateway}
onChange={(e) => setEditingDraft({ ...editingDraft, gateway: e.target.value })}
/>
) : (
<div className="d-flex align-items-center">
<span className="badge bg-green-lt text-green me-2">
<IconServer size={12} />
</span>
<code className="text-green">{filter?.gateway || ''}</code>
</div>
)}
<div className="d-flex align-items-center">
<span className="badge bg-green-lt text-green me-2">
<IconServer size={12} />
</span>
<code className="text-green">{filter?.gateway || ''}</code>
</div>
</td>
<td>
{isEditing ? (
<input
className="form-control form-control-sm"
value={editingDraft.description || ''}
onChange={(e) => setEditingDraft({ ...editingDraft, description: e.target.value })}
/>
) : (
<div className="text-truncate" style={{ maxWidth: '300px' }} title={filter?.description || ''}>
{filter?.description ? (
<span className="text-muted">{filter.description}</span>
) : (
<span className="text-muted fst-italic">Без описания</span>
)}
</div>
)}
<div className="text-truncate" style={{ maxWidth: '300px' }} title={filter?.description || ''}>
{filter?.description ? (
<span className="text-muted">{filter.description}</span>
) : (
<span className="text-muted fst-italic">Без описания</span>
)}
</div>
</td>
<td className="text-end">
<div className="btn-list">
{isEditing ? (
<>
<button className="btn btn-success btn-icon btn-sm" onClick={saveInlineEdit} title="Сохранить">
<IconCheck size={16} />
</button>
<button className="btn btn-secondary btn-icon btn-sm" onClick={cancelInlineEdit} title="Отмена">
<IconX size={16} />
</button>
</>
) : (
<>
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={() => startInlineEdit(filter)}
title="Редактировать фильтр"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => {
setFilterToDelete(filter);
setDeleteFilterModalOpen(true);
}}
title="Удалить фильтр"
>
<IconTrash size={16} />
</button>
</>
)}
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={() => handleEditFilter(filter, index)}
title="Редактировать фильтр"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => {
setFilterToDelete(filter);
setDeleteFilterModalOpen(true);
}}
title="Удалить фильтр"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
@@ -1403,16 +1357,50 @@ function FilterManager() {
helpText="Введите community в формате AS:VALUE"
/>
<FormField
label="Gateway"
name="gateway"
value={newFilter.gateway}
onChange={(val) => setNewFilter({ ...newFilter, gateway: val })}
placeholder="192.168.1.1 или gateway-name"
required
icon={IconServer}
helpText="IP адрес или имя шлюза"
/>
<div className="mb-3">
<label className="form-label required">Gateway</label>
<select
className="form-select"
value={newFilter.gateway}
onChange={(e) => setNewFilter({ ...newFilter, gateway: e.target.value })}
required
>
<option value="">Выберите gateway...</option>
{allGateways.map((gw) => (
<option key={gw.name} value={gw.name}>
{gw.name}{gw.ip ? ` (${gw.ip})` : ''}{gw.serverDns ? `${gw.serverDns}` : ''}
</option>
))}
</select>
{allGateways.length === 0 && (
<div className="form-text text-warning">
<IconAlertTriangle size={14} className="me-1" />
Нет доступных gateway. Добавьте шлюзы в разделе Серверы.
</div>
)}
{newFilter.gateway && (
<div className="mt-2">
{(() => {
const selected = allGateways.find(g => g.name === newFilter.gateway);
if (!selected) return null;
return (
<div className="d-flex align-items-center gap-2 p-2 bg-light rounded">
<span className="avatar avatar-xs bg-primary-lt">
{selected.country ? countryToFlag(selected.country) : <IconServer size={14} />}
</span>
<div className="flex-grow-1">
<div className="fw-bold">{selected.name}</div>
<div className="text-muted small">
{[selected.ip, selected.serverDns, selected.comment].filter(Boolean).join(' · ')}
</div>
</div>
{selected.primary && <span className="badge bg-green-lt text-green">Основной</span>}
</div>
);
})()}
</div>
)}
</div>
<FormField
label="Описание"
@@ -1430,7 +1418,7 @@ function FilterManager() {
setEditFilterModalOpen(false);
setEditingFilter(null);
}}
onSubmit={handleSaveEditFilter}
onSubmit={() => handleSaveEditFilter(editingFilter)}
title="Редактировать фильтр"
submitLabel="Сохранить изменения"
submitIcon={IconCheck}
@@ -1448,15 +1436,61 @@ function FilterManager() {
icon={IconHash}
/>
<FormField
label="Gateway"
name="gateway"
value={editingFilter.gateway}
onChange={(val) => setEditingFilter({ ...editingFilter, gateway: val })}
placeholder="192.168.1.1 или gateway-name"
required
icon={IconServer}
/>
<div className="mb-3">
<label className="form-label required">Gateway</label>
<select
className="form-select"
value={editingFilter.gateway}
onChange={(e) => setEditingFilter({ ...editingFilter, gateway: e.target.value })}
required
>
<option value="">Выберите gateway...</option>
{allGateways.map((gw) => (
<option key={gw.name} value={gw.name}>
{gw.name}{gw.ip ? ` (${gw.ip})` : ''}{gw.serverDns ? `${gw.serverDns}` : ''}
</option>
))}
{/* Если текущий gateway не в списке, добавляем его */}
{editingFilter.gateway && !allGateways.some(g => g.name === editingFilter.gateway) && (
<option value={editingFilter.gateway}>{editingFilter.gateway} (не найден в списке)</option>
)}
</select>
{allGateways.length === 0 && (
<div className="form-text text-warning">
<IconAlertTriangle size={14} className="me-1" />
Нет доступных gateway. Добавьте шлюзы в разделе Серверы.
</div>
)}
{editingFilter.gateway && (
<div className="mt-2">
{(() => {
const selected = allGateways.find(g => g.name === editingFilter.gateway);
if (!selected) {
return (
<div className="d-flex align-items-center gap-2 p-2 bg-warning-lt rounded">
<IconAlertTriangle size={16} className="text-warning" />
<div className="text-muted small">Gateway "{editingFilter.gateway}" не найден в списке серверов</div>
</div>
);
}
return (
<div className="d-flex align-items-center gap-2 p-2 bg-light rounded">
<span className="avatar avatar-xs bg-primary-lt">
{selected.country ? countryToFlag(selected.country) : <IconServer size={14} />}
</span>
<div className="flex-grow-1">
<div className="fw-bold">{selected.name}</div>
<div className="text-muted small">
{[selected.ip, selected.serverDns, selected.comment].filter(Boolean).join(' · ')}
</div>
</div>
{selected.primary && <span className="badge bg-green-lt text-green">Основной</span>}
</div>
);
})()}
</div>
)}
</div>
<FormField
label="Описание"