feat: Advanced UX/UI interface for FilterManager with sidebar navigation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s

This commit is contained in:
2025-11-13 15:23:52 +07:00
parent 66eab3761d
commit a1d59cf36a
+178 -678
View File
@@ -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="Подтверждение"