update ui
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m0s

This commit is contained in:
2025-07-14 20:18:28 +07:00
parent 2bee891548
commit b65d459e5d
+312 -258
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import axios from 'axios'; import axios from 'axios';
import { import {
IconPlus, IconPlus,
@@ -8,34 +8,28 @@ import {
IconCheck, IconCheck,
IconX, IconX,
IconDatabase, IconDatabase,
IconRefresh, IconRefresh
IconDownload,
IconUpload,
IconAlertTriangle,
IconFilter,
IconChevronDown,
IconLink,
IconCopy,
IconEye,
IconServer,
IconSettings,
IconDeviceFloppy,
IconHash,
IconRocket,
IconWorld,
IconNetwork
} from '@tabler/icons-react'; } from '@tabler/icons-react';
const API_URL = '/api'; const API_URL = '/api';
function IPRangesManager() { function IPRangesManager() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false); const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [editingIndex, setEditingIndex] = useState(null); const [editingIp, setEditingIp] = useState(null);
const [editingItem, setEditingItem] = useState({ ipRange: '', community: '' }); const [editingValue, setEditingValue] = useState('');
const [loading, setLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('ipRange');
const [sortOrder, setSortOrder] = useState('asc');
const [filterCommunity, setFilterCommunity] = useState('');
const editInputRef = useRef(null);
const pageSize = 10;
useEffect(() => { useEffect(() => {
fetchItems(); fetchItems();
@@ -49,314 +43,311 @@ function IPRangesManager() {
setError(''); setError('');
} catch (error) { } catch (error) {
console.error('Error fetching ip-ranges:', error); console.error('Error fetching ip-ranges:', error);
setItems([]); setError('Не удалось загрузить IP-диапазоны. Проверьте, запущен ли бэкенд.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleAdd = () => { const handleAddItem = () => {
setEditingIndex(null); if (newItem.ipRange.trim() === '') {
setEditingItem({ ipRange: '', community: '' }); setError('IP-диапазон не может быть пустым.');
};
const handleEdit = (index) => {
setEditingIndex(index);
setEditingItem({ ...items[index] });
};
const handleSave = () => {
if (!editingItem.ipRange.trim() || !editingItem.community.trim()) {
setError('IP-диапазон и Community обязательны для заполнения.');
return; return;
} }
if (newItem.community.trim() === '') {
const updatedItems = [...items]; setError('Community не может быть пустым.');
if (editingIndex !== null) { return;
updatedItems[editingIndex] = { ...editingItem };
} else {
updatedItems.push({ ...editingItem });
} }
setError('');
setItems([...items, newItem]);
setNewItem({ ipRange: '', community: '' });
};
const handleEdit = (item) => {
setEditingIp(item.ipRange);
setEditingValue(item.community);
};
const handleSaveEdit = (ipRange) => {
const updatedItems = items.map(i =>
i.ipRange === ipRange ? { ...i, community: editingValue } : i
);
setItems(updatedItems); setItems(updatedItems);
setEditingIndex(null); setEditingIp(null);
setEditingItem({ ipRange: '', community: '' });
}; };
const handleCancel = () => { const handleCancelEdit = () => {
setEditingIndex(null); setEditingIp(null);
setEditingItem({ ipRange: '', community: '' });
}; };
const handleDelete = (index) => { const handleDeleteItem = (ipRangeToDelete) => {
const updatedItems = items.filter((_, i) => i !== index); setItems(items.filter(i => i.ipRange !== ipRangeToDelete));
setItems(updatedItems);
}; };
const handleSaveToS3 = async () => { const confirmDelete = (item) => {
setItemToDelete(item);
setShowDeleteModal(true);
};
const executeDelete = () => {
if (itemToDelete) {
handleDeleteItem(itemToDelete.ipRange);
setShowDeleteModal(false);
setItemToDelete(null);
}
};
const handleSaveChanges = async () => {
setLoading(true); setLoading(true);
try { try {
await axios.post(`${API_URL}/ip-ranges`, { ipRanges: items }); await axios.post(`${API_URL}/ip-ranges`, { ipRanges: items });
setSuccess('IP-диапазоны успешно сохранены!'); setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
} catch (error) { } catch (error) {
console.error('Error saving ip-ranges:', error); console.error('Error saving changes:', error);
setError('Не удалось сохранить IP-диапазоны.'); setError('Не удалось сохранить изменения.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleUpload = async (event) => { // Сортировка
const file = event.target.files[0]; const sortedItems = [...items].sort((a, b) => {
if (!file) return; let valA = a[sortField] || '';
let valB = b[sortField] || '';
const reader = new FileReader(); if (typeof valA === 'string') valA = valA.toLowerCase();
reader.onload = (e) => { if (typeof valB === 'string') valB = valB.toLowerCase();
const content = e.target.result; if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
const lines = content.split('\n').filter(line => line.trim()); if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
const parsedItems = lines.map(line => { return 0;
const parts = line.trim().split(/\s+/);
return {
ipRange: parts[0] || '',
community: parts[1] || ''
};
}); });
setItems(parsedItems);
setSuccess(`Загружено ${parsedItems.length} IP-диапазонов!`);
setTimeout(() => setSuccess(''), 3000);
};
reader.readAsText(file);
};
const handleDownload = () => { // Фильтрация по community
const content = items.map(item => `${item.ipRange} ${item.community}`).join('\n'); const filteredByCommunity = filterCommunity
const blob = new Blob([content], { type: 'text/plain' }); ? sortedItems.filter(i => i.community === filterCommunity)
const url = URL.createObjectURL(blob); : sortedItems;
const a = document.createElement('a');
a.href = url;
a.download = 'ips.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const filteredItems = items.filter(item => // Поиск
item.ipRange.toLowerCase().includes(searchTerm.toLowerCase()) || const filteredItems = filteredByCommunity.filter(i =>
item.community.toLowerCase().includes(searchTerm.toLowerCase()) i.ipRange.toLowerCase().includes(searchTerm.toLowerCase())
); );
return ( // Пагинация
<div className="container-xl"> const totalPages = Math.ceil(filteredItems.length / pageSize);
<div className="page-header d-print-none mb-4"> const paginatedItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">Управление IP-диапазонами</h2>
<div className="page-pretitle">Главная / IP-диапазоны</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
<button
className="btn btn-outline-primary"
onClick={handleDownload}
disabled={items.length === 0}
>
<IconDownload className="me-2" />
Скачать
</button>
<label className="btn btn-outline-primary">
<IconUpload className="me-2" />
Загрузить
<input
type="file"
accept=".txt"
onChange={handleUpload}
style={{ display: 'none' }}
/>
</label>
<button
className="btn btn-primary"
onClick={handleAdd}
>
<IconPlus className="me-2" />
Добавить IP-диапазон
</button>
</div>
</div>
</div>
</div>
{/* Уведомления */} // Для фильтра - список всех уникальных community
const allCommunities = Array.from(new Set(items.map(i => i.community)));
// Сортировка по клику
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Улучшенный инлайн-редакт
useEffect(() => {
if (editingIp && editInputRef.current) {
editInputRef.current.focus();
}
}, [editingIp]);
const handleEditKeyDown = (e, ipRange) => {
if (e.key === 'Enter') handleSaveEdit(ipRange);
if (e.key === 'Escape') handleCancelEdit();
};
return (
<>
{error && ( {error && (
<div className="alert alert-danger alert-dismissible" role="alert"> <div className="alert alert-danger alert-dismissible" role="alert">
<IconAlertTriangle className="me-2" /> <div className="d-flex">
{error} <div>
<button type="button" className="btn-close" onClick={() => setError('')}></button> <IconX className="icon alert-icon" />
</div> </div>
)} <div>
{success && ( {error}
<div className="alert alert-success alert-dismissible" role="alert"> </div>
<IconCheck className="me-2" /> </div>
{success} <a className="btn-close" onClick={() => setError('')}></a>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div> </div>
)} )}
{/* Панель поиска и сохранения */} {success && (
<div className="card mb-4"> <div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>
{success}
</div>
</div>
<a className="btn-close" onClick={() => setSuccess('')}></a>
</div>
)}
<div className="row">
<div className="col-lg-3">
{/* Add New Item Card */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<IconPlus className="icon me-2" />
Добавить новый IP-диапазон
</h3>
</div>
<div className="card-body"> <div className="card-body">
<div className="row g-3 align-items-center"> <form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
<div className="col-md-6"> <div className="mb-3">
<div className="input-group"> <label className="form-label">IP-диапазон</label>
<span className="input-group-text">
<IconSearch />
</span>
<input <input
type="text" type="text"
className="form-control" className="form-control"
placeholder="Поиск по IP-диапазону или Community..." placeholder="192.168.1.0/24"
value={searchTerm} value={newItem.ipRange}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
/> />
</div> </div>
<div className="mb-3">
<label className="form-label">Community</label>
<input
type="text"
className="form-control"
placeholder="65001:100"
value={newItem.community}
onChange={(e) => setNewItem({ ...newItem, community: e.target.value })}
/>
</div> </div>
<div className="col-md-6"> <div className="form-footer">
<div className="d-flex gap-2 justify-content-end"> <button type="submit" className="btn btn-primary w-100">
<IconPlus className="icon me-2" />
Добавить
</button>
</div>
</form>
</div>
</div>
{/* Actions Card */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<IconDatabase className="icon me-2" />
Действия
</h3>
</div>
<div className="card-body">
<div className="d-grid gap-2">
<button
className="btn btn-primary"
onClick={handleSaveChanges}
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Сохранение...
</>
) : (
<>
<IconDatabase className="icon me-2" />
Сохранить в S3
</>
)}
</button>
<button <button
className="btn btn-outline-secondary" className="btn btn-outline-secondary"
onClick={fetchItems} onClick={fetchItems}
disabled={loading} disabled={loading}
> >
<IconRefresh className="me-2" /> <IconRefresh className="icon me-2" />
Обновить Обновить
</button> </button>
<button
className="btn btn-success"
onClick={handleSaveToS3}
disabled={loading}
>
<IconDeviceFloppy className="me-2" />
Сохранить в S3
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Таблица IP-диапазонов */} <div className="col-lg-9">
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header d-flex justify-content-between align-items-center">
<h3 className="card-title"> <h3 className="card-title mb-0">Список IP-диапазонов</h3>
<IconNetwork className="me-2" /> <div className="d-flex gap-2 w-50">
IP-диапазоны ({filteredItems.length} из {items.length}) {/* Фильтр по community */}
</h3> <select className="form-select w-auto" value={filterCommunity} onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}>
<option value="">Все community</option>
{allCommunities.map(community => (
<option key={community} value={community}>{community}</option>
))}
</select>
{/* Поиск */}
<div className="input-icon flex-grow-1">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск IP-диапазона..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</div> </div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted py-4">
<div className="spinner-border spinner-border-sm me-2" role="status">
<span className="visually-hidden">Загрузка...</span>
</div> </div>
Загрузка IP-диапазонов...
</div> </div>
) : filteredItems.length === 0 ? (
<div className="text-center text-muted py-4">
<IconNetwork className="icon-lg mb-2" />
<p>Нет IP-диапазонов</p>
<button
className="btn btn-primary"
onClick={handleAdd}
>
<IconPlus className="me-2" />
Добавить первый IP-диапазон
</button>
</div>
) : (
<div className="table-responsive"> <div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0"> <table className="table card-table table-vcenter table-nowrap mb-0">
<thead> <thead>
<tr> <tr>
<th style={{ width: '40%' }}>IP-ДИАПАЗОН</th> <th style={{cursor:'pointer'}} onClick={() => handleSort('ipRange')}>
<th style={{ width: '40%' }}>COMMUNITY</th> IP-диапазон
<th style={{ width: '20%' }} className="text-end">ДЕЙСТВИЯ</th> {sortField === 'ipRange' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('community')}>
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th className="text-end"> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredItems.map((item, index) => ( {paginatedItems.map((item) => (
<tr key={index}> <tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-info' : ''}>
<td> <td>{item.ipRange}</td>
{editingIndex === index ? ( <td><span className="badge bg-blue-lt text-blue">{item.community}</span></td>
<input
type="text"
className="form-control form-control-sm"
value={editingItem.ipRange}
onChange={(e) => setEditingItem({ ...editingItem, ipRange: e.target.value })}
placeholder="192.168.1.0/24"
/>
) : (
<div className="d-flex align-items-center">
<span className="badge bg-blue-lt text-blue me-2">
<IconNetwork size={12} />
</span>
<code className="text-blue">{item.ipRange}</code>
</div>
)}
</td>
<td>
{editingIndex === index ? (
<input
type="text"
className="form-control form-control-sm"
value={editingItem.community}
onChange={(e) => setEditingItem({ ...editingItem, community: e.target.value })}
placeholder="65001:100"
/>
) : (
<div className="d-flex align-items-center">
<span className="badge bg-green-lt text-green me-2">
<IconHash size={12} />
</span>
<code className="text-green">{item.community}</code>
</div>
)}
</td>
<td className="text-end"> <td className="text-end">
{editingIndex === index ? ( {editingIp === item.ipRange ? (
<div className="btn-list"> <>
<button <input
className="btn btn-outline-success btn-icon btn-sm" type="text"
onClick={handleSave} className="form-control d-inline-block w-auto me-2"
title="Сохранить" value={editingValue}
> ref={editInputRef}
<IconCheck size={16} /> onChange={e => setEditingValue(e.target.value)}
</button> onKeyDown={e => handleEditKeyDown(e, item.ipRange)}
<button style={{maxWidth: 120}}
className="btn btn-outline-secondary btn-icon btn-sm" />
onClick={handleCancel} <button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(item.ipRange)}><IconCheck size={18} /></button>
title="Отмена" <button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
> </>
<IconX size={16} />
</button>
</div>
) : ( ) : (
<div className="btn-list"> <>
<button <button className="btn btn-outline-primary btn-icon me-1" onClick={() => handleEdit(item)}><IconEdit size={18} /></button>
className="btn btn-outline-primary btn-icon btn-sm" <button className="btn btn-outline-danger btn-icon" onClick={() => confirmDelete(item)}><IconTrash size={18} /></button>
onClick={() => handleEdit(index)} </>
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => handleDelete(index)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
)} )}
</td> </td>
</tr> </tr>
@@ -364,10 +355,73 @@ function IPRangesManager() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Пагинация */}
{totalPages > 1 && (
<div className="card-footer d-flex justify-content-center">
<ul className="pagination m-0">
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} tabIndex={-1} disabled={currentPage === 1}>&laquo;</button>
</li>
{Array.from({length: totalPages}, (_, i) => i + 1).map(page => (
<li key={page} className={`page-item${currentPage === page ? ' active' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(page)}>{page}</button>
</li>
))}
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} tabIndex={-1} disabled={currentPage === totalPages}>&raquo;</button>
</li>
</ul>
</div>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div className="modal-content">
<button
type="button"
className="btn-close"
onClick={() => setShowDeleteModal(false)}
></button>
<div className="modal-status bg-danger"></div>
<div className="modal-body text-center py-4">
<IconTrash className="icon mb-2 text-danger icon-lg" />
<h3>Удалить IP-диапазон?</h3>
<div className="text-muted">
Вы уверены, что хотите удалить "{itemToDelete?.ipRange}"? Это действие необратимо.
</div>
</div>
<div className="modal-footer">
<div className="w-100">
<div className="row">
<div className="col">
<button
className="btn w-100"
onClick={() => setShowDeleteModal(false)}
>
Отмена
</button>
</div>
<div className="col">
<button
className="btn btn-danger w-100"
onClick={executeDelete}
>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</>
); );
} }