feat: Advanced UX/UI interface for FilterManager with sidebar navigation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
This commit is contained in:
+178
-678
@@ -65,19 +65,15 @@ function FilterManager() {
|
||||
const [editingServer, setEditingServer] = useState(null);
|
||||
const [editServerError, setEditServerError] = useState('');
|
||||
|
||||
// Новое состояние для режима
|
||||
const [mode, setMode] = useState('advanced'); // 'simple' | 'advanced'
|
||||
const [simpleFilters, setSimpleFilters] = useState([]);
|
||||
const [simpleAddFilter, setSimpleAddFilter] = useState({ community: '', gateway: '', description: '' });
|
||||
const [simpleError, setSimpleError] = useState('');
|
||||
const [simpleSuccess, setSimpleSuccess] = useState('');
|
||||
const [simpleLoading, setSimpleLoading] = useState(false);
|
||||
|
||||
// Состояние поиска/сортировки в расширенном режиме
|
||||
// Состояние поиска/сортировки
|
||||
const [filterSearch, setFilterSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState('community'); // community | gateway | description
|
||||
const [sortDir, setSortDir] = useState('asc'); // asc | desc
|
||||
|
||||
// Состояние sidebar
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [serverSearch, setServerSearch] = useState('');
|
||||
|
||||
// Состояние для инлайн-редактирования и массовых действий
|
||||
const [editingKey, setEditingKey] = useState(null);
|
||||
const [editingDraft, setEditingDraft] = useState({ community: '', gateway: '', description: '' });
|
||||
@@ -104,7 +100,6 @@ function FilterManager() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
fetchSimpleFilters();
|
||||
}, []);
|
||||
|
||||
// Загружаем фильтры при выборе сервера
|
||||
@@ -142,18 +137,6 @@ function FilterManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSimpleFilters = async () => {
|
||||
setSimpleLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/simple-filters`);
|
||||
setSimpleFilters(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching simple filters:', error);
|
||||
setSimpleFilters([]);
|
||||
} finally {
|
||||
setSimpleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddServer = async () => {
|
||||
if (!newServer.name.trim()) {
|
||||
@@ -606,19 +589,15 @@ function FilterManager() {
|
||||
}
|
||||
};
|
||||
|
||||
// Состояние для поиска
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Фильтрация серверов по поиску
|
||||
const filteredServers = servers.filter(server =>
|
||||
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(server.description && server.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
server.name.toLowerCase().includes(serverSearch.toLowerCase()) ||
|
||||
(server.description && server.description.toLowerCase().includes(serverSearch.toLowerCase()))
|
||||
);
|
||||
|
||||
// Статистика
|
||||
const totalServers = servers.length;
|
||||
const enabledServers = servers.filter(s => s.enabled).length;
|
||||
const serversWithFilters = servers.filter(s => s.id === selectedServer?.id ? serverFilters.length > 0 : false).length;
|
||||
// Разделение серверов на активные и неактивные
|
||||
const activeServers = filteredServers.filter(s => s.enabled);
|
||||
const inactiveServers = filteredServers.filter(s => !s.enabled);
|
||||
|
||||
// Преобразованные фильтры для таблицы (поиск + сортировка)
|
||||
const processedFilters = (() => {
|
||||
@@ -737,133 +716,175 @@ function FilterManager() {
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для применения фильтров из упрощённого режима на все сервера
|
||||
const handleDeploySimpleFiltersToAll = async () => {
|
||||
if (servers.length === 0) {
|
||||
setSimpleError('Нет серверов для применения фильтров.');
|
||||
return;
|
||||
}
|
||||
if (simpleFilters.length === 0) {
|
||||
setSimpleError('Нет фильтров для применения.');
|
||||
return;
|
||||
}
|
||||
setSimpleLoading(true);
|
||||
try {
|
||||
// Копируем фильтры и генерируем конфиг для каждого сервера
|
||||
for (const server of servers) {
|
||||
await api.post(`/server-filters/${server.id}`, { filters: simpleFilters });
|
||||
// Генерируем конфиг
|
||||
const response = await api.post(`/server-filters/generate-config`, { filters: simpleFilters });
|
||||
if (response.data && response.data.config) {
|
||||
await api.post(`/server-configs/${server.id}`, { config: response.data.config });
|
||||
}
|
||||
}
|
||||
setSimpleSuccess('Фильтры и конфигурация успешно применены ко всем серверам!');
|
||||
setTimeout(() => setSimpleSuccess(''), 4000);
|
||||
} catch (error) {
|
||||
setSimpleError('Ошибка при применении фильтров: ' + (error.response?.data || error.message));
|
||||
} finally {
|
||||
setSimpleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Функции для работы с фильтрами в упрощённом режиме
|
||||
const handleSimpleAddFilter = async () => {
|
||||
if (!simpleAddFilter.community.trim() || !simpleAddFilter.gateway.trim()) {
|
||||
setSimpleError('Поля Community и Gateway должны быть заполнены.');
|
||||
return;
|
||||
}
|
||||
const updatedFilters = [...simpleFilters, { ...simpleAddFilter }];
|
||||
setSimpleFilters(updatedFilters);
|
||||
setSimpleAddFilter({ community: '', gateway: '', description: '' });
|
||||
setSimpleError('');
|
||||
|
||||
// Сохраняем фильтры
|
||||
try {
|
||||
await api.post(`/simple-filters`, { filters: updatedFilters });
|
||||
setSimpleSuccess('Фильтр успешно добавлен!');
|
||||
setTimeout(() => setSimpleSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error saving simple filters:', error);
|
||||
setSimpleError('Не удалось сохранить фильтр.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSimpleDeleteFilter = async (filter) => {
|
||||
const updatedFilters = simpleFilters.filter(f => f !== filter);
|
||||
setSimpleFilters(updatedFilters);
|
||||
|
||||
// Сохраняем фильтры
|
||||
try {
|
||||
await api.post(`/simple-filters`, { filters: updatedFilters });
|
||||
setSimpleSuccess('Фильтр успешно удален!');
|
||||
setTimeout(() => setSimpleSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error saving simple filters:', error);
|
||||
setSimpleError('Не удалось удалить фильтр.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSimpleEditFilter = async (index, field, value) => {
|
||||
const updated = [...simpleFilters];
|
||||
updated[index][field] = value;
|
||||
setSimpleFilters(updated);
|
||||
|
||||
// Сохраняем фильтры
|
||||
try {
|
||||
await api.post(`/simple-filters`, { filters: updated });
|
||||
} catch (error) {
|
||||
console.error('Error saving simple filters:', error);
|
||||
setSimpleError('Не удалось сохранить изменения.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header d-print-none mb-4">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h2 className="page-title">Управление фильтрами frouting</h2>
|
||||
<div className="page-pretitle">Главная / Фильтры</div>
|
||||
<div className="page">
|
||||
{/* Боковая панель для выбора сервера */}
|
||||
{!sidebarCollapsed && (
|
||||
<aside className="navbar navbar-vertical navbar-expand-lg" style={{ width: '280px' }}>
|
||||
<div className="container-fluid">
|
||||
<div className="navbar-nav">
|
||||
<div className="nav-item mb-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong className="text-uppercase text-muted" style={{ fontSize: '0.65rem', letterSpacing: '0.04em' }}>Серверы</strong>
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
title="Свернуть панель"
|
||||
>
|
||||
<IconChevronDown size={16} style={{ transform: 'rotate(90deg)' }} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Поиск..."
|
||||
value={serverSearch}
|
||||
onChange={(e) => setServerSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Активные серверы */}
|
||||
{activeServers.length > 0 && (
|
||||
<div className="nav-item mb-3">
|
||||
<div className="text-uppercase text-muted mb-2" style={{ fontSize: '0.625rem', letterSpacing: '0.04em' }}>
|
||||
Активные ({activeServers.length})
|
||||
</div>
|
||||
{activeServers.map(server => (
|
||||
<a
|
||||
key={server.id}
|
||||
href="#"
|
||||
className={`nav-link ${selectedServer?.id === server.id ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedServer(server);
|
||||
}}
|
||||
>
|
||||
<span className="nav-link-icon">
|
||||
<IconServer size={18} />
|
||||
</span>
|
||||
<span className="nav-link-title">{server.name}</span>
|
||||
{selectedServer?.id === server.id && serverFilters.length > 0 && (
|
||||
<span className="badge bg-primary ms-auto">{serverFilters.length}</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Неактивные серверы */}
|
||||
{inactiveServers.length > 0 && (
|
||||
<div className="nav-item mb-3">
|
||||
<div className="text-uppercase text-muted mb-2" style={{ fontSize: '0.625rem', letterSpacing: '0.04em' }}>
|
||||
Неактивные ({inactiveServers.length})
|
||||
</div>
|
||||
{inactiveServers.map(server => (
|
||||
<a
|
||||
key={server.id}
|
||||
href="#"
|
||||
className={`nav-link ${selectedServer?.id === server.id ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedServer(server);
|
||||
}}
|
||||
>
|
||||
<span className="nav-link-icon">
|
||||
<IconServer size={18} className="text-muted" />
|
||||
</span>
|
||||
<span className="nav-link-title text-muted">{server.name}</span>
|
||||
{selectedServer?.id === server.id && serverFilters.length > 0 && (
|
||||
<span className="badge bg-secondary ms-auto">{serverFilters.length}</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка добавления сервера */}
|
||||
<div className="nav-item mt-auto">
|
||||
<button
|
||||
className="btn btn-primary btn-sm w-100"
|
||||
onClick={() => setServerConfigModalOpen(true)}
|
||||
>
|
||||
<IconPlus size={16} className="me-1" />
|
||||
Добавить сервер
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Кнопка для разворачивания боковой панели */}
|
||||
{sidebarCollapsed && (
|
||||
<div className="position-fixed top-0 start-0 p-3" style={{ zIndex: 1000 }}>
|
||||
<button
|
||||
className="btn btn-primary btn-icon"
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
title="Показать панель серверов"
|
||||
>
|
||||
<IconServer size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="page-wrapper">
|
||||
<div className="page-header d-print-none">
|
||||
<div className="container-xl">
|
||||
<div className="row g-2 align-items-center">
|
||||
<div className="col">
|
||||
<h2 className="page-title">
|
||||
Управление фильтрами frouting
|
||||
</h2>
|
||||
<div className="page-pretitle">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="breadcrumb breadcrumb-arrows">
|
||||
<li className="breadcrumb-item">Главная</li>
|
||||
<li className="breadcrumb-item">Фильтры</li>
|
||||
{selectedServer && <li className="breadcrumb-item active">{selectedServer.name}</li>}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{selectedServer && (
|
||||
<div className="col-auto ms-auto d-print-none">
|
||||
<div className="btn-list">
|
||||
{selectedServer && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={handlePreviewConfig}
|
||||
disabled={serverFilters.length === 0}
|
||||
title={serverFilters.length === 0 ? "Сначала добавьте фильтры" : "Предварительный просмотр конфигурации"}
|
||||
>
|
||||
<IconEye className="me-2" />
|
||||
Предварительный просмотр
|
||||
<IconEye className="icon" />
|
||||
Просмотр
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={copyConfigToClipboard}
|
||||
disabled={serverFilters.length === 0}
|
||||
title={serverFilters.length === 0 ? "Сначала добавьте фильтры" : "Копировать конфигурацию в буфер"}
|
||||
>
|
||||
<IconCopy className="me-2" />
|
||||
Копировать конфигурацию
|
||||
<IconCopy className="icon" />
|
||||
Копировать
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-success"
|
||||
className="btn btn-success"
|
||||
onClick={() => handleSaveServerConfig(selectedServer.id)}
|
||||
disabled={loading || serverFilters.length === 0}
|
||||
title={serverFilters.length === 0 ? "Сначала добавьте фильтры" : "Сохранить конфигурацию"}
|
||||
>
|
||||
<IconDeviceFloppy className="me-2" />
|
||||
Сохранить конфигурацию
|
||||
<IconDeviceFloppy className="icon" />
|
||||
Сохранить
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="container-xl">
|
||||
{/* Уведомления */}
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
@@ -880,321 +901,61 @@ function FilterManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Панель статистики */}
|
||||
{mode === 'advanced' && (
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-3">
|
||||
<div className="card card-sm">
|
||||
<div className="card-body">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-auto">
|
||||
<span className="bg-primary text-white avatar">
|
||||
<IconServer />
|
||||
</span>
|
||||
{/* Пустое состояние - выбор сервера */}
|
||||
{!selectedServer ? (
|
||||
<div className="empty">
|
||||
<div className="empty-icon">
|
||||
<IconServer size={48} className="text-muted" />
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="font-weight-medium">{totalServers}</div>
|
||||
<div className="text-muted">Всего серверов</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="card card-sm">
|
||||
<div className="card-body">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-auto">
|
||||
<span className="bg-green text-white avatar">
|
||||
<IconCheck />
|
||||
</span>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="font-weight-medium">{enabledServers}</div>
|
||||
<div className="text-muted">Активных серверов</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="card card-sm">
|
||||
<div className="card-body">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-auto">
|
||||
<span className="bg-blue text-white avatar">
|
||||
<IconFilter />
|
||||
</span>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="font-weight-medium">{selectedServer ? serverFilters.length : '-'}</div>
|
||||
<div className="text-muted">
|
||||
{selectedServer ? 'Фильтров в выбранном сервере' : 'Выберите сервер'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="card card-sm">
|
||||
<div className="card-body">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-auto">
|
||||
<span className="bg-orange text-white avatar">
|
||||
<IconDeviceFloppy />
|
||||
</span>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="font-weight-medium">
|
||||
{!selectedServer ? 'Не выбран' : serverFilters.length > 0 ? 'Готов' : 'Не готов'}
|
||||
</div>
|
||||
<div className="text-muted">Статус конфигурации</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Секция управления серверами */}
|
||||
{mode === 'advanced' && (
|
||||
<div className="row mb-4">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconServer className="me-2" />
|
||||
Готовые конфигурации серверов
|
||||
</h3>
|
||||
<div className="card-actions">
|
||||
<div className="input-group" style={{ maxWidth: 400 }}>
|
||||
<span className="input-group-text">
|
||||
<IconSearch />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск серверов..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
onClick={() => setServerConfigModalOpen(true)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<IconPlus className="me-1" />
|
||||
Добавить сервер
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{servers.length === 0 ? (
|
||||
<div className="text-center text-muted py-4">
|
||||
<IconServer className="icon-lg mb-2" />
|
||||
<p>Нет добавленных серверов</p>
|
||||
<p className="empty-title">Выберите сервер</p>
|
||||
<p className="empty-subtitle text-muted">
|
||||
Выберите сервер из списка слева для управления его фильтрами
|
||||
</p>
|
||||
{servers.length === 0 && (
|
||||
<div className="empty-action">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setServerConfigModalOpen(true)}
|
||||
>
|
||||
<IconPlus className="me-2" />
|
||||
<IconPlus className="icon" />
|
||||
Добавить первый сервер
|
||||
</button>
|
||||
</div>
|
||||
) : filteredServers.length === 0 ? (
|
||||
<div className="text-center text-muted py-4">
|
||||
<IconSearch className="icon-lg mb-2" />
|
||||
<p>Серверы не найдены по запросу "{searchTerm}"</p>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => setSearchTerm('')}
|
||||
>
|
||||
Очистить поиск
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row g-3">
|
||||
{filteredServers.map(server => (
|
||||
<div key={server.id} className="col-md-6 col-lg-4">
|
||||
<div className={`card ${selectedServer?.id === server.id ? 'border-primary' : ''} h-100`}>
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-3">
|
||||
<div className="flex-grow-1">
|
||||
<h4 className="card-title mb-1 d-flex align-items-center">
|
||||
{server.name}
|
||||
{selectedServer?.id === server.id && (
|
||||
<span className="badge bg-blue-lt text-blue ms-2">Выбран</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span className={`badge ${server.enabled ? 'bg-green-lt text-green' : 'bg-red-lt text-red'}`}>
|
||||
{server.enabled ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
{server.id === selectedServer?.id && (
|
||||
<span className="badge bg-blue-lt text-blue">
|
||||
{serverFilters.length} фильтров
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={server.enabled}
|
||||
onChange={async () => {
|
||||
const updatedServers = servers.map(s =>
|
||||
s.id === server.id ? { ...s, enabled: !s.enabled } : s
|
||||
);
|
||||
try {
|
||||
await api.post(`/server-configs`, { servers: updatedServers });
|
||||
setServers(updatedServers);
|
||||
} catch (error) {
|
||||
setError('Не удалось обновить статус сервера');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.description && (
|
||||
<p className="text-muted small mb-3">{server.description}</p>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
<button
|
||||
className={`btn btn-sm ${selectedServer?.id === server.id ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setSelectedServer(server)}
|
||||
>
|
||||
<IconFilter className="me-1" />
|
||||
{selectedServer?.id === server.id ? 'Выбран' : 'Выбрать'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={() => handleEditServer(server)}
|
||||
title="Редактировать сервер"
|
||||
>
|
||||
<IconEdit className="me-1" />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-warning btn-sm"
|
||||
onClick={() => handleCopyConfigToAllServers(server.id)}
|
||||
disabled={loading || servers.filter(s => s.id !== server.id && s.enabled).length === 0}
|
||||
title="Скопировать конфигурацию на все остальные серверы"
|
||||
>
|
||||
<IconCopy className="me-1" />
|
||||
Копировать на все
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => handleDeleteServer(server.id)}
|
||||
>
|
||||
<IconTrash className="me-1" />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Секция фильтров выбранного сервера */}
|
||||
{selectedServer && (
|
||||
<div className="row mb-4">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center g-2">
|
||||
<div className="col">
|
||||
<h3 className="card-title">
|
||||
<IconFilter className="me-2" />
|
||||
Фильтры сервера: {selectedServer.name}
|
||||
<span className="badge bg-green-lt text-green ms-2">
|
||||
{serverFilters.length} фильтров
|
||||
<span className="badge bg-blue-lt text-blue ms-2">
|
||||
{serverFilters.length}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="text-muted">
|
||||
Управление фильтрами community/gateway для генерации конфигурации MikroTik
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-auto">
|
||||
<div className="input-group" style={{ maxWidth: 360 }}>
|
||||
<span className="input-group-text">
|
||||
<IconSearch size={16} />
|
||||
</span>
|
||||
<div className="card-actions">
|
||||
<div className="input-icon me-2">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск по community, gateway, описанию"
|
||||
placeholder="Поиск фильтров..."
|
||||
value={filterSearch}
|
||||
onChange={(e) => setFilterSearch(e.target.value)}
|
||||
/>
|
||||
{filterSearch && (
|
||||
<button className="btn btn-outline-secondary" onClick={() => setFilterSearch('')}>Сброс</button>
|
||||
)}
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-auto">
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
onClick={handlePreviewConfig}
|
||||
disabled={serverFilters.length === 0}
|
||||
title={serverFilters.length === 0 ? "Сначала добавьте фильтры" : "Предварительный просмотр конфигурации"}
|
||||
>
|
||||
<IconEye className="me-2" />
|
||||
Предварительный просмотр
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
onClick={copyConfigToClipboard}
|
||||
disabled={serverFilters.length === 0}
|
||||
title={serverFilters.length === 0 ? "Сначала добавьте фильтры" : "Копировать конфигурацию в буфер"}
|
||||
>
|
||||
<IconCopy className="me-2" />
|
||||
Копировать конфигурацию
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-success btn-sm"
|
||||
onClick={() => handleSaveServerConfig(selectedServer.id)}
|
||||
disabled={loading || serverFilters.length === 0}
|
||||
title={serverFilters.length === 0 ? "Сначала добавьте фильтры" : "Сохранить конфигурацию"}
|
||||
>
|
||||
<IconDeviceFloppy className="me-2" />
|
||||
Сохранить конфигурацию
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setAddFilterModalOpen(true)}
|
||||
>
|
||||
<IconPlus className="me-2" />
|
||||
<IconPlus className="icon" />
|
||||
Добавить фильтр
|
||||
</button>
|
||||
{serverFilters.length > 0 && (
|
||||
<button
|
||||
className="btn btn-outline-warning btn-sm"
|
||||
onClick={handleDeployToAllServers}
|
||||
disabled={loading || servers.filter(s => s.enabled && s.id !== selectedServer.id).length === 0}
|
||||
title="Развернуть фильтры и конфигурацию на все активные серверы"
|
||||
>
|
||||
<IconRocket className="me-2" />
|
||||
Развернуть на все серверы
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
@@ -1372,9 +1133,10 @@ function FilterManager() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальные окна */}
|
||||
<FormModal
|
||||
@@ -1656,268 +1418,6 @@ function FilterManager() {
|
||||
)}
|
||||
</FormModal>
|
||||
|
||||
{/* Модальное окно упрощённого режима */}
|
||||
<div className="mb-4">
|
||||
{mode === 'simple' ? (
|
||||
<button
|
||||
className="btn btn-outline-primary btn-lg w-100"
|
||||
onClick={() => setMode('advanced')}
|
||||
>
|
||||
<IconServer className="me-2" /> Перейти в расширенный режим
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-lg w-100"
|
||||
onClick={() => setMode('simple')}
|
||||
>
|
||||
<IconFilter className="me-2" /> Вернуться в упрощённый режим
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mode === 'simple' ? (
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-title">
|
||||
<IconFilter className="me-2" /> Фильтры (упрощённый режим)
|
||||
</h3>
|
||||
<div className="text-muted">
|
||||
Управление фильтрами community/gateway для всех серверов
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<div className="badge bg-blue-lt text-blue">
|
||||
{simpleFilters.length} фильтров
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{simpleError && (
|
||||
<div className="alert alert-danger py-2" role="alert">{simpleError}</div>
|
||||
)}
|
||||
{simpleSuccess && (
|
||||
<div className="alert alert-success py-2" role="alert">{simpleSuccess}</div>
|
||||
)}
|
||||
<div className="table-responsive mb-3">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '25%' }}>COMMUNITY</th>
|
||||
<th style={{ width: '25%' }}>GATEWAY</th>
|
||||
<th style={{ width: '35%' }}>ОПИСАНИЕ</th>
|
||||
<th style={{ width: '15%' }} className="text-end">ДЕЙСТВИЯ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{simpleLoading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center text-muted py-4">
|
||||
<div className="spinner-border spinner-border-sm me-2" role="status">
|
||||
<span className="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
Загрузка фильтров...
|
||||
</td>
|
||||
</tr>
|
||||
) : simpleFilters.length === 0 ? (
|
||||
<tr><td colSpan={4} className="text-center text-muted">Нет фильтров</td></tr>
|
||||
) : (
|
||||
simpleFilters.filter(f => f != null).map((filter, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
value={filter?.community || ''}
|
||||
onChange={e => handleSimpleEditFilter(idx, 'community', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
value={filter?.gateway || ''}
|
||||
onChange={e => handleSimpleEditFilter(idx, 'gateway', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
value={filter?.description || ''}
|
||||
onChange={e => handleSimpleEditFilter(idx, 'description', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<button
|
||||
className="btn btn-outline-danger btn-icon btn-sm"
|
||||
onClick={() => handleSimpleDeleteFilter(filter)}
|
||||
title="Удалить фильтр"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="row g-2 align-items-end mb-3">
|
||||
<div className="col-md-4">
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Community"
|
||||
value={simpleAddFilter.community}
|
||||
onChange={e => setSimpleAddFilter({ ...simpleAddFilter, community: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Gateway"
|
||||
value={simpleAddFilter.gateway}
|
||||
onChange={e => setSimpleAddFilter({ ...simpleAddFilter, gateway: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Описание (необязательно)"
|
||||
value={simpleAddFilter.description}
|
||||
onChange={e => setSimpleAddFilter({ ...simpleAddFilter, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-1">
|
||||
<button className="btn btn-primary w-100" onClick={handleSimpleAddFilter}>
|
||||
<IconPlus />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.post(`/server-filters/generate-config`, { filters: simpleFilters });
|
||||
if (response.data && response.data.config) {
|
||||
setGeneratedConfig(response.data.config);
|
||||
setPreviewMode('simple');
|
||||
setPreviewModalOpen(true);
|
||||
} else {
|
||||
setSimpleError('Ошибка генерации конфигурации');
|
||||
}
|
||||
} catch (error) {
|
||||
setSimpleError('Ошибка при генерации конфигурации: ' + (error.response?.data || error.message));
|
||||
}
|
||||
}}
|
||||
disabled={simpleFilters.length === 0 || simpleLoading}
|
||||
title={simpleFilters.length === 0 ? "Сначала добавьте фильтры" : "Предварительный просмотр конфигурации"}
|
||||
>
|
||||
<IconEye className="me-2" /> Предварительный просмотр
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.post(`/server-filters/generate-config`, { filters: simpleFilters });
|
||||
if (response.data && response.data.config) {
|
||||
const config = response.data.config;
|
||||
if (navigator.clipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(config);
|
||||
setSimpleSuccess('Конфигурация скопирована в буфер обмена!');
|
||||
setTimeout(() => setSimpleSuccess(''), 3000);
|
||||
} catch (err) {
|
||||
setSimpleError('Не удалось скопировать конфигурацию');
|
||||
}
|
||||
} else {
|
||||
// Fallback для старых браузеров
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = config;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setSimpleSuccess('Конфигурация скопирована в буфер обмена!');
|
||||
setTimeout(() => setSimpleSuccess(''), 3000);
|
||||
} catch (err) {
|
||||
setSimpleError('Не удалось скопировать конфигурацию');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
} else {
|
||||
setSimpleError('Ошибка генерации конфигурации');
|
||||
}
|
||||
} catch (error) {
|
||||
setSimpleError('Ошибка при генерации конфигурации: ' + (error.response?.data || error.message));
|
||||
}
|
||||
}}
|
||||
disabled={simpleFilters.length === 0 || simpleLoading}
|
||||
title={simpleFilters.length === 0 ? "Сначала добавьте фильтры" : "Копировать конфигурацию в буфер"}
|
||||
>
|
||||
<IconCopy className="me-2" /> Копировать конфигурацию
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-success"
|
||||
onClick={handleDeploySimpleFiltersToAll}
|
||||
disabled={simpleFilters.length === 0 || simpleLoading}
|
||||
>
|
||||
<IconRocket className="me-2" /> Раскатать фильтры на все серверы
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.post(`/simple-filters`, { filters: simpleFilters });
|
||||
setSimpleSuccess('Фильтры успешно сохранены!');
|
||||
setTimeout(() => setSimpleSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
setSimpleError('Ошибка при сохранении фильтров: ' + (error.response?.data || error.message));
|
||||
}
|
||||
}}
|
||||
disabled={simpleLoading}
|
||||
>
|
||||
<IconDeviceFloppy className="me-2" /> Сохранить фильтры
|
||||
</button>
|
||||
{simpleFilters.length > 0 && (
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
onClick={async () => {
|
||||
let proceed = false;
|
||||
await new Promise((resolve) => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
text: 'Вы уверены, что хотите очистить все фильтры?',
|
||||
onConfirm: () => { proceed = true; setConfirmState({ open: false, text: '', onConfirm: null }); resolve(); },
|
||||
});
|
||||
});
|
||||
if (proceed) {
|
||||
try {
|
||||
await api.post(`/simple-filters`, { filters: [] });
|
||||
setSimpleFilters([]);
|
||||
setSimpleSuccess('Все фильтры очищены!');
|
||||
setTimeout(() => setSimpleSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
setSimpleError('Ошибка при очистке фильтров: ' + (error.response?.data || error.message));
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={simpleLoading}
|
||||
>
|
||||
<IconTrash className="me-2" /> Очистить все
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// ... существующий расширенный режим ...
|
||||
<>
|
||||
{/* Панель статистики */}
|
||||
{/* Секция управления серверами */}
|
||||
{/* ... всё как было ... */}
|
||||
</>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmState.open}
|
||||
title="Подтверждение"
|
||||
|
||||
Reference in New Issue
Block a user