feat: Add server management functionality with new API endpoints and update UI to include server statistics
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 14m34s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 14m34s
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
# 📂 S3 Lists Manager
|
# 📂 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 операции:** Полный набор действий: создание, чтение, обновление и удаление записей.
|
- **CRUD операции:** Полный набор действий: создание, чтение, обновление и удаление записей.
|
||||||
- **Поиск в реальном времени:** Мгновенная фильтрация списков по мере ввода.
|
- **Поиск в реальном времени:** Мгновенная фильтрация списков по мере ввода.
|
||||||
- **Массовая замена:** Быстрое обновление шлюзов для сотен записей в один клик.
|
- **Массовая замена:** Быстрое обновление шлюзов для сотен записей в один клик.
|
||||||
@@ -44,6 +48,7 @@ graph TD
|
|||||||
C{S3 Bucket}
|
C{S3 Bucket}
|
||||||
D1[domains.txt]
|
D1[domains.txt]
|
||||||
D2[asns.txt]
|
D2[asns.txt]
|
||||||
|
D3[servers.json]
|
||||||
end
|
end
|
||||||
|
|
||||||
A -- HTTP Requests --> B
|
A -- HTTP Requests --> B
|
||||||
@@ -197,4 +202,6 @@ docker-compose up -d
|
|||||||
| `GET` | `/api/domains`| Получить список всех доменов. |
|
| `GET` | `/api/domains`| Получить список всех доменов. |
|
||||||
| `POST` | `/api/domains`| Сохранить изменения в `domains.txt`. |
|
| `POST` | `/api/domains`| Сохранить изменения в `domains.txt`. |
|
||||||
| `GET` | `/api/asns` | Получить список всех AS. |
|
| `GET` | `/api/asns` | Получить список всех AS. |
|
||||||
| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. |
|
| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. |
|
||||||
|
| `GET` | `/api/servers`| Получить список всех серверов. |
|
||||||
|
| `POST` | `/api/servers`| Сохранить изменения в `servers.json`. |
|
||||||
+74
-3
@@ -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
|
// Новый эндпоинт для получения дат последнего изменения файлов S3
|
||||||
app.get('/api/s3/last-modified', async (req, res) => {
|
app.get('/api/s3/last-modified', async (req, res) => {
|
||||||
try {
|
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: '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({
|
res.json({
|
||||||
domainsLastModified: domainsHead.LastModified ? domainsHead.LastModified.toISOString() : null,
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -42,6 +42,20 @@
|
|||||||
box-shadow: none !important;
|
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 */
|
/* Custom responsive adjustments */
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
|
|||||||
+23
-7
@@ -15,9 +15,11 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
IconDatabase,
|
IconDatabase,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconAlertCircle
|
IconAlertCircle,
|
||||||
|
IconServer
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import DataManager from './DataManager';
|
import DataManager from './DataManager';
|
||||||
|
import ServerManager from './ServerManager';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ function App() {
|
|||||||
{ id: 'dashboard', title: 'Dashboard', icon: IconHome },
|
{ id: 'dashboard', title: 'Dashboard', icon: IconHome },
|
||||||
{ id: 'domains', title: 'Домены', icon: IconWorld },
|
{ id: 'domains', title: 'Домены', icon: IconWorld },
|
||||||
{ id: 'asns', title: 'AS', icon: IconNetwork },
|
{ id: 'asns', title: 'AS', icon: IconNetwork },
|
||||||
|
{ id: 'servers', title: 'Серверы', icon: IconServer },
|
||||||
{ id: 'files', title: 'Файлы', icon: IconFileText },
|
{ id: 'files', title: 'Файлы', icon: IconFileText },
|
||||||
{ id: 'cloud', title: 'Облако', icon: IconCloud },
|
{ id: 'cloud', title: 'Облако', icon: IconCloud },
|
||||||
{ id: 'security', title: 'Безопасность', icon: IconShield },
|
{ id: 'security', title: 'Безопасность', icon: IconShield },
|
||||||
@@ -59,6 +62,8 @@ function App() {
|
|||||||
placeholder="AS12345"
|
placeholder="AS12345"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'servers':
|
||||||
|
return <ServerManager />;
|
||||||
default:
|
default:
|
||||||
return <Dashboard />;
|
return <Dashboard />;
|
||||||
}
|
}
|
||||||
@@ -172,25 +177,27 @@ function StatCard({ icon: Icon, color, value, title, subtitle }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard() {
|
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);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [domainsRes, asnsRes, s3Res] = await Promise.all([
|
const [domainsRes, asnsRes, serversRes, s3Res] = await Promise.all([
|
||||||
axios.get('/api/domains'),
|
axios.get('/api/domains'),
|
||||||
axios.get('/api/asns'),
|
axios.get('/api/asns'),
|
||||||
|
axios.get('/api/servers'),
|
||||||
axios.get('/api/s3/last-modified')
|
axios.get('/api/s3/last-modified')
|
||||||
]);
|
]);
|
||||||
setStats({
|
setStats({
|
||||||
domainsCount: domainsRes.data.length,
|
domainsCount: domainsRes.data.length,
|
||||||
asnsCount: asnsRes.data.length,
|
asnsCount: asnsRes.data.length,
|
||||||
|
serversCount: serversRes.data.length,
|
||||||
lastModified: s3Res.data.domainsLastModified
|
lastModified: s3Res.data.domainsLastModified
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStats({ domainsCount: null, asnsCount: null, lastModified: null });
|
setStats({ domainsCount: null, asnsCount: null, serversCount: null, lastModified: null });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -201,7 +208,7 @@ function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="row g-3 mb-4">
|
<div className="row g-3 mb-4">
|
||||||
<div className="col-md-4">
|
<div className="col-md-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={IconWorld}
|
icon={IconWorld}
|
||||||
color="blue"
|
color="blue"
|
||||||
@@ -210,7 +217,7 @@ function Dashboard() {
|
|||||||
subtitle="Всего доменов"
|
subtitle="Всего доменов"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-md-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={IconNetwork}
|
icon={IconNetwork}
|
||||||
color="green"
|
color="green"
|
||||||
@@ -219,7 +226,16 @@ function Dashboard() {
|
|||||||
subtitle="Всего AS"
|
subtitle="Всего AS"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-md-3">
|
||||||
|
<StatCard
|
||||||
|
icon={IconServer}
|
||||||
|
color="purple"
|
||||||
|
value={loading ? '...' : stats.serversCount ?? '—'}
|
||||||
|
title="Серверов"
|
||||||
|
subtitle="Всего серверов"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={IconDatabase}
|
icon={IconDatabase}
|
||||||
color="orange"
|
color="orange"
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconSearch,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
IconCheck,
|
||||||
|
IconX,
|
||||||
|
IconDatabase,
|
||||||
|
IconRefresh,
|
||||||
|
IconArrowsExchange,
|
||||||
|
IconDownload,
|
||||||
|
IconUpload,
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconServer,
|
||||||
|
IconChevronDown
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
const API_URL = '/api';
|
||||||
|
|
||||||
|
function ServerManager() {
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
const [newServer, setNewServer] = useState({
|
||||||
|
ip: '',
|
||||||
|
dns: '',
|
||||||
|
country: '',
|
||||||
|
provider: '',
|
||||||
|
tunnel: 'GRE'
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [editingServer, setEditingServer] = useState(null);
|
||||||
|
const [editingValues, setEditingValues] = useState({});
|
||||||
|
const [bulkFrom, setBulkFrom] = useState('GRE');
|
||||||
|
const [bulkTo, setBulkTo] = useState('IPSec');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [serverToDelete, setServerToDelete] = useState(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [sortField, setSortField] = useState('ip');
|
||||||
|
const [sortOrder, setSortOrder] = useState('asc');
|
||||||
|
const [filterTunnel, setFilterTunnel] = useState('');
|
||||||
|
const [filterCountry, setFilterCountry] = useState('');
|
||||||
|
const [filterProvider, setFilterProvider] = useState('');
|
||||||
|
const editInputRef = useRef(null);
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 && (
|
||||||
|
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||||
|
<div className="d-flex">
|
||||||
|
<div>
|
||||||
|
<IconX className="icon alert-icon" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a className="btn-close" onClick={() => setError('')}></a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<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-4">
|
||||||
|
{/* Add New Server Card */}
|
||||||
|
<div className="card card-md">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">
|
||||||
|
<IconPlus className="icon me-2" />
|
||||||
|
Добавить новый сервер
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleAddServer(); }}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">IP адрес</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
value={newServer.ip}
|
||||||
|
onChange={(e) => setNewServer({ ...newServer, ip: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">DNS имя</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="srv1.shx.su"
|
||||||
|
value={newServer.dns}
|
||||||
|
onChange={(e) => setNewServer({ ...newServer, dns: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Страна</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="RU"
|
||||||
|
value={newServer.country}
|
||||||
|
onChange={(e) => setNewServer({ ...newServer, country: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Провайдер</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Yandex"
|
||||||
|
value={newServer.provider}
|
||||||
|
onChange={(e) => setNewServer({ ...newServer, provider: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Тип туннеля</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="GRE"
|
||||||
|
value={newServer.tunnel}
|
||||||
|
onChange={(e) => setNewServer({ ...newServer, tunnel: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-footer">
|
||||||
|
<button type="submit" className="btn btn-primary w-100">
|
||||||
|
<IconPlus className="icon me-2" />
|
||||||
|
Добавить сервер
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Update Card */}
|
||||||
|
<div className="card card-md mt-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">
|
||||||
|
<IconArrowsExchange className="icon me-2" />
|
||||||
|
Массовое обновление туннелей
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Заменить с</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={bulkFrom}
|
||||||
|
onChange={(e) => setBulkFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Заменить на</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={bulkTo}
|
||||||
|
onChange={(e) => setBulkTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-warning w-100"
|
||||||
|
onClick={handleBulkUpdate}
|
||||||
|
>
|
||||||
|
<IconArrowsExchange className="icon me-2" />
|
||||||
|
Выполнить замену
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Changes Card */}
|
||||||
|
<div className="card card-md mt-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">
|
||||||
|
<IconDatabase className="icon me-2" />
|
||||||
|
Сохранение изменений
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Все изменения сохраняются в файл servers.json в S3 хранилище.
|
||||||
|
</p>
|
||||||
|
<div className="form-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-success w-100"
|
||||||
|
onClick={handleSaveChanges}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<IconRefresh className="icon me-2 spin" />
|
||||||
|
Сохранение...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconDatabase className="icon me-2" />
|
||||||
|
Сохранить в S3
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h3 className="card-title mb-0">Список серверов</h3>
|
||||||
|
<div className="d-flex gap-2 w-75">
|
||||||
|
{/* Фильтры */}
|
||||||
|
<select className="form-select w-auto" value={filterTunnel} onChange={e => { setFilterTunnel(e.target.value); setCurrentPage(1); }}>
|
||||||
|
<option value="">Все туннели</option>
|
||||||
|
{allTunnels.map(tunnel => (
|
||||||
|
<option key={tunnel} value={tunnel}>{tunnel}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select className="form-select w-auto" value={filterCountry} onChange={e => { setFilterCountry(e.target.value); setCurrentPage(1); }}>
|
||||||
|
<option value="">Все страны</option>
|
||||||
|
{allCountries.map(country => (
|
||||||
|
<option key={country} value={country}>{country}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select className="form-select w-auto" value={filterProvider} onChange={e => { setFilterProvider(e.target.value); setCurrentPage(1); }}>
|
||||||
|
<option value="">Все провайдеры</option>
|
||||||
|
{allProviders.map(provider => (
|
||||||
|
<option key={provider} value={provider}>{provider}</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="Поиск серверов..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{cursor:'pointer'}} onClick={() => handleSort('ip')}>
|
||||||
|
IP адрес
|
||||||
|
{sortField === 'ip' && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
<th style={{cursor:'pointer'}} onClick={() => handleSort('dns')}>
|
||||||
|
DNS имя
|
||||||
|
{sortField === 'dns' && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
<th style={{cursor:'pointer'}} onClick={() => handleSort('country')}>
|
||||||
|
Страна
|
||||||
|
{sortField === 'country' && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
<th style={{cursor:'pointer'}} onClick={() => handleSort('provider')}>
|
||||||
|
Провайдер
|
||||||
|
{sortField === 'provider' && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
<th style={{cursor:'pointer'}} onClick={() => handleSort('tunnel')}>
|
||||||
|
Туннель
|
||||||
|
{sortField === 'tunnel' && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
<th className="text-end">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedServers.map((server) => (
|
||||||
|
<tr key={server.ip} className={editingServer === server.ip ? 'table-info' : ''}>
|
||||||
|
<td>{server.ip}</td>
|
||||||
|
<td>{server.dns}</td>
|
||||||
|
<td><span className="badge bg-blue-lt text-blue">{server.country}</span></td>
|
||||||
|
<td>{server.provider}</td>
|
||||||
|
<td><span className="badge bg-green-lt text-green">{server.tunnel}</span></td>
|
||||||
|
<td className="text-end">
|
||||||
|
{editingServer === server.ip ? (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(server.ip)}><IconCheck size={18} /></button>
|
||||||
|
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-outline-primary btn-icon me-1" onClick={() => handleEdit(server)}><IconEdit size={18} /></button>
|
||||||
|
<button className="btn btn-outline-danger btn-icon" onClick={() => confirmDelete(server)}><IconTrash size={18} /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Пагинация */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="card-footer d-flex align-items-center">
|
||||||
|
<p className="m-0 text-muted">
|
||||||
|
Показано <span>{(currentPage - 1) * pageSize + 1}</span> до <span>{Math.min(currentPage * pageSize, filteredServers.length)}</span> из <span>{filteredServers.length}</span> серверов
|
||||||
|
</p>
|
||||||
|
<ul className="pagination m-0 ms-auto">
|
||||||
|
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>
|
||||||
|
<IconChevronDown className="icon icon-sm rotate-180" />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>
|
||||||
|
Вперед
|
||||||
|
<IconChevronDown className="icon icon-sm" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{showDeleteModal && (
|
||||||
|
<div className="modal fade show" style={{display: 'block'}} tabIndex="-1">
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Подтверждение удаления</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={() => setShowDeleteModal(false)}></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p>Вы уверены, что хотите удалить сервер <strong>{serverToDelete?.ip}</strong>?</p>
|
||||||
|
<p className="text-muted">Это действие нельзя отменить.</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setShowDeleteModal(false)}>Отмена</button>
|
||||||
|
<button type="button" className="btn btn-danger" onClick={executeDelete}>Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServerManager;
|
||||||
Reference in New Issue
Block a user