diff --git a/README.md b/README.md index 4ff4ae6..fe9f5ab 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # 📂 S3 Lists Manager -Веб-интерфейс для удобного управления текстовыми файлами (`domains.txt`, `asns.txt`) в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в этих файлах. +Веб-интерфейс для удобного управления файлами в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в следующих файлах: + +- **domains.txt** - список доменов и их шлюзов +- **asns.txt** - список AS номеров и их шлюзов +- **servers.json** - список серверов с расширенной информацией (IP, DNS, страна, провайдер, тип туннеля) ## ✨ Возможности -- **Два режима работы:** Управление списками доменов и AS-номеров через вкладки. +- **Три режима работы:** Управление списками доменов, AS-номеров и серверов через вкладки. - **CRUD операции:** Полный набор действий: создание, чтение, обновление и удаление записей. - **Поиск в реальном времени:** Мгновенная фильтрация списков по мере ввода. - **Массовая замена:** Быстрое обновление шлюзов для сотен записей в один клик. @@ -44,6 +48,7 @@ graph TD C{S3 Bucket} D1[domains.txt] D2[asns.txt] + D3[servers.json] end A -- HTTP Requests --> B @@ -197,4 +202,6 @@ docker-compose up -d | `GET` | `/api/domains`| Получить список всех доменов. | | `POST` | `/api/domains`| Сохранить изменения в `domains.txt`. | | `GET` | `/api/asns` | Получить список всех AS. | -| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. | \ No newline at end of file +| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. | +| `GET` | `/api/servers`| Получить список всех серверов. | +| `POST` | `/api/servers`| Сохранить изменения в `servers.json`. | \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index f1cbfda..12d2f07 100644 --- a/backend/server.js +++ b/backend/server.js @@ -126,16 +126,87 @@ app.post('/api/asns', async (req, res) => { } }); +// --- Servers Routes (JSON format) --- + +// Get servers from S3 +app.get('/api/servers', async (req, res) => { + const params = { + Bucket: BUCKET_NAME, + Key: 'servers.json', + }; + + try { + const data = await s3.getObject(params).promise(); + const fileContent = data.Body.toString('utf-8'); + let servers = []; + + try { + servers = JSON.parse(fileContent); + // Ensure it's an array + if (!Array.isArray(servers)) { + servers = []; + } + } catch (parseError) { + console.error('Error parsing servers.json:', parseError); + servers = []; + } + + res.json(servers); + } catch (error) { + if (error.code === 'NoSuchKey') { + res.json([]); // Return empty array if file does not exist + } else { + console.error(error); + res.status(500).send('Error reading from S3'); + } + } +}); + +// Update servers in S3 +app.post('/api/servers', async (req, res) => { + const { domains: servers } = req.body; // Keep name 'domains' for consistency + + // Validate servers structure + if (!Array.isArray(servers)) { + return res.status(400).send('Servers must be an array'); + } + + // Validate each server has required fields + for (let i = 0; i < servers.length; i++) { + const server = servers[i]; + if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) { + return res.status(400).send(`Server at index ${i} is missing required fields`); + } + } + + const params = { + Bucket: BUCKET_NAME, + Key: 'servers.json', + Body: JSON.stringify(servers, null, 2), // Pretty print JSON + ContentType: 'application/json', + }; + + try { + await s3.putObject(params).promise(); + res.send('File updated successfully'); + } catch (error) { + console.error(error); + res.status(500).send('Error writing to S3'); + } +}); + // Новый эндпоинт для получения дат последнего изменения файлов S3 app.get('/api/s3/last-modified', async (req, res) => { try { - const [domainsHead, asnsHead] = await Promise.all([ + const [domainsHead, asnsHead, serversHead] = await Promise.all([ s3.headObject({ Bucket: BUCKET_NAME, Key: 'domains.txt' }).promise(), - s3.headObject({ Bucket: BUCKET_NAME, Key: 'asns.txt' }).promise() + s3.headObject({ Bucket: BUCKET_NAME, Key: 'asns.txt' }).promise(), + s3.headObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise() ]); res.json({ domainsLastModified: domainsHead.LastModified ? domainsHead.LastModified.toISOString() : null, - asnsLastModified: asnsHead.LastModified ? asnsHead.LastModified.toISOString() : null + asnsLastModified: asnsHead.LastModified ? asnsHead.LastModified.toISOString() : null, + serversLastModified: serversHead.LastModified ? serversHead.LastModified.toISOString() : null }); } catch (error) { console.error(error); diff --git a/example-servers.json b/example-servers.json new file mode 100644 index 0000000..57dd170 --- /dev/null +++ b/example-servers.json @@ -0,0 +1,37 @@ +[ + { + "ip": "192.168.1.1", + "dns": "srv1.shx.su", + "country": "RU", + "provider": "Yandex", + "tunnel": "GRE" + }, + { + "ip": "10.0.0.5", + "dns": "srv2.shx.su", + "country": "US", + "provider": "AWS", + "tunnel": "IPSec" + }, + { + "ip": "172.16.0.10", + "dns": "srv3.shx.su", + "country": "DE", + "provider": "Hetzner", + "tunnel": "GRE" + }, + { + "ip": "8.8.8.8", + "dns": "dns.google", + "country": "US", + "provider": "Google", + "tunnel": "IPSec" + }, + { + "ip": "1.1.1.1", + "dns": "cloudflare-dns.com", + "country": "US", + "provider": "Cloudflare", + "tunnel": "GRE" + } +] \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index 86c8979..894b3a2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -42,6 +42,20 @@ box-shadow: none !important; } +/* Spinning animation for loading icons */ +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + /* Custom responsive adjustments */ @media (max-width: 992px) { .page-wrapper { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 088af32..7d77bdb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,9 +15,11 @@ import { IconUser, IconDatabase, IconAlertTriangle, - IconAlertCircle + IconAlertCircle, + IconServer } from '@tabler/icons-react'; import DataManager from './DataManager'; +import ServerManager from './ServerManager'; import './App.css'; import axios from 'axios'; @@ -32,6 +34,7 @@ function App() { { id: 'dashboard', title: 'Dashboard', icon: IconHome }, { id: 'domains', title: 'Домены', icon: IconWorld }, { id: 'asns', title: 'AS', icon: IconNetwork }, + { id: 'servers', title: 'Серверы', icon: IconServer }, { id: 'files', title: 'Файлы', icon: IconFileText }, { id: 'cloud', title: 'Облако', icon: IconCloud }, { id: 'security', title: 'Безопасность', icon: IconShield }, @@ -59,6 +62,8 @@ function App() { placeholder="AS12345" /> ); + case 'servers': + return ; default: return ; } @@ -172,25 +177,27 @@ function StatCard({ icon: Icon, color, value, title, subtitle }) { } function Dashboard() { - const [stats, setStats] = useState({ domainsCount: null, asnsCount: null, lastModified: null }); + const [stats, setStats] = useState({ domainsCount: null, asnsCount: null, serversCount: null, lastModified: null }); const [loading, setLoading] = useState(true); useEffect(() => { async function fetchStats() { setLoading(true); try { - const [domainsRes, asnsRes, s3Res] = await Promise.all([ + const [domainsRes, asnsRes, serversRes, s3Res] = await Promise.all([ axios.get('/api/domains'), axios.get('/api/asns'), + axios.get('/api/servers'), axios.get('/api/s3/last-modified') ]); setStats({ domainsCount: domainsRes.data.length, asnsCount: asnsRes.data.length, + serversCount: serversRes.data.length, lastModified: s3Res.data.domainsLastModified }); } catch (e) { - setStats({ domainsCount: null, asnsCount: null, lastModified: null }); + setStats({ domainsCount: null, asnsCount: null, serversCount: null, lastModified: null }); } finally { setLoading(false); } @@ -201,7 +208,7 @@ function Dashboard() { return (
-
+
-
+
-
+
+ +
+
{ + fetchServers(); + }, []); + + const fetchServers = async () => { + setLoading(true); + try { + const response = await axios.get(`${API_URL}/servers`); + setServers(response.data); + setError(''); + } catch (error) { + console.error('Error fetching servers:', error); + setError('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.'); + } finally { + setLoading(false); + } + }; + + const handleAddServer = () => { + if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim()) { + setError('Все поля должны быть заполнены.'); + return; + } + setError(''); + setServers([...servers, { ...newServer }]); + setNewServer({ ip: '', dns: '', country: '', provider: '', tunnel: 'GRE' }); + }; + + const handleEdit = (server) => { + setEditingServer(server.ip); + setEditingValues({ ...server }); + }; + + const handleSaveEdit = (serverIp) => { + const updatedServers = servers.map(s => + s.ip === serverIp ? { ...s, ...editingValues } : s + ); + setServers(updatedServers); + setEditingServer(null); + }; + + const handleCancelEdit = () => { + setEditingServer(null); + }; + + const handleDeleteServer = (serverIp) => { + setServers(servers.filter(s => s.ip !== serverIp)); + }; + + const confirmDelete = (server) => { + setServerToDelete(server); + setShowDeleteModal(true); + }; + + const executeDelete = () => { + if (serverToDelete) { + handleDeleteServer(serverToDelete.ip); + setShowDeleteModal(false); + setServerToDelete(null); + } + }; + + const handleSaveChanges = async () => { + setLoading(true); + try { + await axios.post(`${API_URL}/servers`, { domains: servers }); + setSuccess('Изменения успешно сохранены!'); + setTimeout(() => setSuccess(''), 3000); + } catch (error) { + console.error('Error saving changes:', error); + setError('Не удалось сохранить изменения.'); + } finally { + setLoading(false); + } + }; + + const handleBulkUpdate = () => { + if (!bulkFrom || !bulkTo) { + setError('Оба поля для массового обновления должны быть заполнены.'); + return; + } + const confirm = window.confirm(`Вы уверены, что хотите заменить все туннели "${bulkFrom}" на "${bulkTo}"? Это действие необратимо.`); + if (confirm) { + const updatedServers = servers.map(s => + s.tunnel === bulkFrom ? { ...s, tunnel: bulkTo } : s + ); + setServers(updatedServers); + setSuccess(`Туннели "${bulkFrom}" были успешно заменены на "${bulkTo}". Не забудьте сохранить изменения.`); + } + }; + + // Сортировка + const sortedServers = [...servers].sort((a, b) => { + let valA = a[sortField] || ''; + let valB = b[sortField] || ''; + if (typeof valA === 'string') valA = valA.toLowerCase(); + if (typeof valB === 'string') valB = valB.toLowerCase(); + if (valA < valB) return sortOrder === 'asc' ? -1 : 1; + if (valA > valB) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + + // Фильтрация + let filteredServers = sortedServers; + + if (filterTunnel) { + filteredServers = filteredServers.filter(s => s.tunnel === filterTunnel); + } + + if (filterCountry) { + filteredServers = filteredServers.filter(s => s.country === filterCountry); + } + + if (filterProvider) { + filteredServers = filteredServers.filter(s => s.provider === filterProvider); + } + + // Поиск + filteredServers = filteredServers.filter(s => + s.ip.toLowerCase().includes(searchTerm.toLowerCase()) || + s.dns.toLowerCase().includes(searchTerm.toLowerCase()) || + s.country.toLowerCase().includes(searchTerm.toLowerCase()) || + s.provider.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // Пагинация + const totalPages = Math.ceil(filteredServers.length / pageSize); + const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + // Для фильтров - списки всех уникальных значений + const allTunnels = Array.from(new Set(servers.map(s => s.tunnel))); + const allCountries = Array.from(new Set(servers.map(s => s.country))); + const allProviders = Array.from(new Set(servers.map(s => s.provider))); + + // Сортировка по клику + const handleSort = (field) => { + if (sortField === field) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortOrder('asc'); + } + }; + + // Улучшенный инлайн-редакт + useEffect(() => { + if (editingServer && editInputRef.current) { + editInputRef.current.focus(); + } + }, [editingServer]); + + const handleEditKeyDown = (e, serverIp) => { + if (e.key === 'Enter') handleSaveEdit(serverIp); + if (e.key === 'Escape') handleCancelEdit(); + }; + + return ( + <> + {error && ( +
+
+
+ +
+
+ {error} +
+
+ setError('')}> +
+ )} + + {success && ( +
+
+
+ +
+
+ {success} +
+
+ setSuccess('')}> +
+ )} + +
+
+ {/* Add New Server Card */} +
+
+

+ + Добавить новый сервер +

+
+
+
{ e.preventDefault(); handleAddServer(); }}> +
+ + setNewServer({ ...newServer, ip: e.target.value })} + /> +
+
+ + setNewServer({ ...newServer, dns: e.target.value })} + /> +
+
+ + setNewServer({ ...newServer, country: e.target.value })} + /> +
+
+ + setNewServer({ ...newServer, provider: e.target.value })} + /> +
+
+ + setNewServer({ ...newServer, tunnel: e.target.value })} + /> +
+
+ +
+
+
+
+ + {/* Bulk Update Card */} +
+
+

+ + Массовое обновление туннелей +

+
+
+
+ + setBulkFrom(e.target.value)} + /> +
+
+ + setBulkTo(e.target.value)} + /> +
+
+ +
+
+
+ + {/* Save Changes Card */} +
+
+

+ + Сохранение изменений +

+
+
+

+ Все изменения сохраняются в файл servers.json в S3 хранилище. +

+
+ +
+
+
+
+ +
+
+
+

Список серверов

+
+ {/* Фильтры */} + + + + {/* Поиск */} +
+ + + + { setSearchTerm(e.target.value); setCurrentPage(1); }} + /> +
+
+
+
+ + + + + + + + + + + + + {paginatedServers.map((server) => ( + + + + + + + + + ))} + +
handleSort('ip')}> + IP адрес + {sortField === 'ip' && ( + + {sortOrder === 'asc' ? '▲' : '▼'} + + )} + handleSort('dns')}> + DNS имя + {sortField === 'dns' && ( + + {sortOrder === 'asc' ? '▲' : '▼'} + + )} + handleSort('country')}> + Страна + {sortField === 'country' && ( + + {sortOrder === 'asc' ? '▲' : '▼'} + + )} + handleSort('provider')}> + Провайдер + {sortField === 'provider' && ( + + {sortOrder === 'asc' ? '▲' : '▼'} + + )} + handleSort('tunnel')}> + Туннель + {sortField === 'tunnel' && ( + + {sortOrder === 'asc' ? '▲' : '▼'} + + )} + Действия
{server.ip}{server.dns}{server.country}{server.provider}{server.tunnel} + {editingServer === server.ip ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ {/* Пагинация */} + {totalPages > 1 && ( +
+

+ Показано {(currentPage - 1) * pageSize + 1} до {Math.min(currentPage * pageSize, filteredServers.length)} из {filteredServers.length} серверов +

+
    +
  • + +
  • +
  • + +
  • +
+
+ )} +
+
+
+ + {/* Delete Modal */} + {showDeleteModal && ( +
+
+
+
+
Подтверждение удаления
+ +
+
+

Вы уверены, что хотите удалить сервер {serverToDelete?.ip}?

+

Это действие нельзя отменить.

+
+
+ + +
+
+
+
+ )} + + ); +} + +export default ServerManager; \ No newline at end of file