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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s
This commit is contained in:
+171
-137
@@ -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="Описание"
|
||||
|
||||
Reference in New Issue
Block a user