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 [editingServer, setEditingServer] = useState(null);
|
||||||
const [editServerError, setEditServerError] = useState('');
|
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 [filterSearch, setFilterSearch] = useState('');
|
||||||
const [sortBy, setSortBy] = useState('community'); // community | gateway | description
|
const [sortBy, setSortBy] = useState('community'); // community | gateway | description
|
||||||
const [sortDir, setSortDir] = useState('asc'); // asc | desc
|
const [sortDir, setSortDir] = useState('asc'); // asc | desc
|
||||||
|
|
||||||
|
// Состояние sidebar
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [serverSearch, setServerSearch] = useState('');
|
||||||
|
|
||||||
// Состояние для инлайн-редактирования и массовых действий
|
// Состояние для инлайн-редактирования и массовых действий
|
||||||
const [editingKey, setEditingKey] = useState(null);
|
const [editingKey, setEditingKey] = useState(null);
|
||||||
const [editingDraft, setEditingDraft] = useState({ community: '', gateway: '', description: '' });
|
const [editingDraft, setEditingDraft] = useState({ community: '', gateway: '', description: '' });
|
||||||
@@ -104,7 +100,6 @@ function FilterManager() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
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 () => {
|
const handleAddServer = async () => {
|
||||||
if (!newServer.name.trim()) {
|
if (!newServer.name.trim()) {
|
||||||
@@ -606,19 +589,15 @@ function FilterManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Состояние для поиска
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
// Фильтрация серверов по поиску
|
// Фильтрация серверов по поиску
|
||||||
const filteredServers = servers.filter(server =>
|
const filteredServers = servers.filter(server =>
|
||||||
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
server.name.toLowerCase().includes(serverSearch.toLowerCase()) ||
|
||||||
(server.description && server.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
(server.description && server.description.toLowerCase().includes(serverSearch.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Статистика
|
// Разделение серверов на активные и неактивные
|
||||||
const totalServers = servers.length;
|
const activeServers = filteredServers.filter(s => s.enabled);
|
||||||
const enabledServers = servers.filter(s => s.enabled).length;
|
const inactiveServers = filteredServers.filter(s => !s.enabled);
|
||||||
const serversWithFilters = servers.filter(s => s.id === selectedServer?.id ? serverFilters.length > 0 : false).length;
|
|
||||||
|
|
||||||
// Преобразованные фильтры для таблицы (поиск + сортировка)
|
// Преобразованные фильтры для таблицы (поиск + сортировка)
|
||||||
const processedFilters = (() => {
|
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 (
|
return (
|
||||||
<div>
|
<div className="page">
|
||||||
<div className="page-header d-print-none mb-4">
|
{/* Боковая панель для выбора сервера */}
|
||||||
<div className="row align-items-center">
|
{!sidebarCollapsed && (
|
||||||
<div className="col">
|
<aside className="navbar navbar-vertical navbar-expand-lg" style={{ width: '280px' }}>
|
||||||
<h2 className="page-title">Управление фильтрами frouting</h2>
|
<div className="container-fluid">
|
||||||
<div className="page-pretitle">Главная / Фильтры</div>
|
<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>
|
||||||
|
<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="col-auto ms-auto d-print-none">
|
||||||
<div className="btn-list">
|
<div className="btn-list">
|
||||||
{selectedServer && (
|
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
onClick={handlePreviewConfig}
|
onClick={handlePreviewConfig}
|
||||||
disabled={serverFilters.length === 0}
|
disabled={serverFilters.length === 0}
|
||||||
title={serverFilters.length === 0 ? "Сначала добавьте фильтры" : "Предварительный просмотр конфигурации"}
|
|
||||||
>
|
>
|
||||||
<IconEye className="me-2" />
|
<IconEye className="icon" />
|
||||||
Предварительный просмотр
|
Просмотр
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
onClick={copyConfigToClipboard}
|
onClick={copyConfigToClipboard}
|
||||||
disabled={serverFilters.length === 0}
|
disabled={serverFilters.length === 0}
|
||||||
title={serverFilters.length === 0 ? "Сначала добавьте фильтры" : "Копировать конфигурацию в буфер"}
|
|
||||||
>
|
>
|
||||||
<IconCopy className="me-2" />
|
<IconCopy className="icon" />
|
||||||
Копировать конфигурацию
|
Копировать
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-success"
|
className="btn btn-success"
|
||||||
onClick={() => handleSaveServerConfig(selectedServer.id)}
|
onClick={() => handleSaveServerConfig(selectedServer.id)}
|
||||||
disabled={loading || serverFilters.length === 0}
|
disabled={loading || serverFilters.length === 0}
|
||||||
title={serverFilters.length === 0 ? "Сначала добавьте фильтры" : "Сохранить конфигурацию"}
|
|
||||||
>
|
>
|
||||||
<IconDeviceFloppy className="me-2" />
|
<IconDeviceFloppy className="icon" />
|
||||||
Сохранить конфигурацию
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="page-body">
|
||||||
|
<div className="container-xl">
|
||||||
{/* Уведомления */}
|
{/* Уведомления */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||||
@@ -880,321 +901,61 @@ function FilterManager() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Панель статистики */}
|
{/* Пустое состояние - выбор сервера */}
|
||||||
{mode === 'advanced' && (
|
{!selectedServer ? (
|
||||||
<div className="row mb-4">
|
<div className="empty">
|
||||||
<div className="col-md-3">
|
<div className="empty-icon">
|
||||||
<div className="card card-sm">
|
<IconServer size={48} className="text-muted" />
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-primary text-white avatar">
|
|
||||||
<IconServer />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col">
|
<p className="empty-title">Выберите сервер</p>
|
||||||
<div className="font-weight-medium">{totalServers}</div>
|
<p className="empty-subtitle text-muted">
|
||||||
<div className="text-muted">Всего серверов</div>
|
Выберите сервер из списка слева для управления его фильтрами
|
||||||
</div>
|
</p>
|
||||||
</div>
|
{servers.length === 0 && (
|
||||||
</div>
|
<div className="empty-action">
|
||||||
</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>
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => setServerConfigModalOpen(true)}
|
onClick={() => setServerConfigModalOpen(true)}
|
||||||
>
|
>
|
||||||
<IconPlus className="me-2" />
|
<IconPlus className="icon" />
|
||||||
Добавить первый сервер
|
Добавить первый сервер
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
) : null}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Секция фильтров выбранного сервера */}
|
{/* Секция фильтров выбранного сервера */}
|
||||||
{selectedServer && (
|
{selectedServer && (
|
||||||
<div className="row mb-4">
|
|
||||||
<div className="col-12">
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="row align-items-center g-2">
|
|
||||||
<div className="col">
|
|
||||||
<h3 className="card-title">
|
<h3 className="card-title">
|
||||||
<IconFilter className="me-2" />
|
<IconFilter className="me-2" />
|
||||||
Фильтры сервера: {selectedServer.name}
|
Фильтры сервера: {selectedServer.name}
|
||||||
<span className="badge bg-green-lt text-green ms-2">
|
<span className="badge bg-blue-lt text-blue ms-2">
|
||||||
{serverFilters.length} фильтров
|
{serverFilters.length}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-muted">
|
<div className="card-actions">
|
||||||
Управление фильтрами community/gateway для генерации конфигурации MikroTik
|
<div className="input-icon me-2">
|
||||||
</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>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder="Поиск по community, gateway, описанию"
|
placeholder="Поиск фильтров..."
|
||||||
value={filterSearch}
|
value={filterSearch}
|
||||||
onChange={(e) => setFilterSearch(e.target.value)}
|
onChange={(e) => setFilterSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{filterSearch && (
|
<span className="input-icon-addon">
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setFilterSearch('')}>Сброс</button>
|
<IconSearch />
|
||||||
)}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-auto">
|
|
||||||
<div className="btn-list">
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary btn-sm"
|
className="btn btn-primary"
|
||||||
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"
|
|
||||||
onClick={() => setAddFilterModalOpen(true)}
|
onClick={() => setAddFilterModalOpen(true)}
|
||||||
>
|
>
|
||||||
<IconPlus className="me-2" />
|
<IconPlus className="icon" />
|
||||||
Добавить фильтр
|
Добавить фильтр
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
@@ -1372,9 +1133,10 @@ function FilterManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Модальные окна */}
|
{/* Модальные окна */}
|
||||||
<FormModal
|
<FormModal
|
||||||
@@ -1656,268 +1418,6 @@ function FilterManager() {
|
|||||||
)}
|
)}
|
||||||
</FormModal>
|
</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
|
<ConfirmDialog
|
||||||
open={confirmState.open}
|
open={confirmState.open}
|
||||||
title="Подтверждение"
|
title="Подтверждение"
|
||||||
|
|||||||
Reference in New Issue
Block a user