feat: Add server availability check API and integrate it into the Dashboard and ServerManager components for enhanced server monitoring and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
This commit is contained in:
@@ -5,6 +5,7 @@ const cors = require('cors');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const compression = require('compression');
|
const compression = require('compression');
|
||||||
const Ajv = require('ajv');
|
const Ajv = require('ajv');
|
||||||
|
const net = require('net');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3001;
|
const port = 3001;
|
||||||
@@ -1395,6 +1396,55 @@ app.get('/api/auto-urls', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Servers availability check ---
|
||||||
|
// Simple TCP connect check to 80 then 443 with short timeouts
|
||||||
|
function tcpCheck(host, port, timeoutMs) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let settled = false;
|
||||||
|
const onOk = () => { if (!settled) { settled = true; try{ socket.destroy(); }catch{} resolve(true); } };
|
||||||
|
const onFail = () => { if (!settled) { settled = true; try{ socket.destroy(); }catch{} resolve(false); } };
|
||||||
|
socket.setTimeout(timeoutMs);
|
||||||
|
socket.once('error', onFail);
|
||||||
|
socket.once('timeout', onFail);
|
||||||
|
socket.connect(port, host, onOk);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOneServer(srv, timeoutMs = 1500) {
|
||||||
|
const hostCandidates = [];
|
||||||
|
if (srv.ip) hostCandidates.push(srv.ip);
|
||||||
|
if (srv.dns) hostCandidates.push(srv.dns);
|
||||||
|
for (const host of hostCandidates) {
|
||||||
|
// try 80 then 443
|
||||||
|
const ok80 = await tcpCheck(host, 80, timeoutMs);
|
||||||
|
if (ok80) return true;
|
||||||
|
const ok443 = await tcpCheck(host, 443, timeoutMs);
|
||||||
|
if (ok443) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/servers/availability', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await s3.getObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise();
|
||||||
|
let servers = [];
|
||||||
|
try {
|
||||||
|
servers = JSON.parse(data.Body.toString('utf-8'));
|
||||||
|
if (!Array.isArray(servers)) servers = [];
|
||||||
|
} catch {
|
||||||
|
servers = [];
|
||||||
|
}
|
||||||
|
const checks = await Promise.allSettled(servers.map(s => checkOneServer(s)));
|
||||||
|
const statuses = servers.map((s, i) => ({ ip: s.ip, dns: s.dns, online: checks[i].status === 'fulfilled' ? Boolean(checks[i].value) : false }));
|
||||||
|
const online = statuses.filter(x => x.online).length;
|
||||||
|
res.json({ online, total: servers.length, statuses });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('availability error', e);
|
||||||
|
res.status(500).json({ online: 0, total: 0, statuses: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update auto URLs in S3
|
// Update auto URLs in S3
|
||||||
app.post('/api/auto-urls', async (req, res) => {
|
app.post('/api/auto-urls', async (req, res) => {
|
||||||
const { urls } = req.body;
|
const { urls } = req.body;
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ function Dashboard() {
|
|||||||
axios.get('/api/ip-ranges'),
|
axios.get('/api/ip-ranges'),
|
||||||
axios.get('/api/asns'),
|
axios.get('/api/asns'),
|
||||||
axios.get('/api/servers'),
|
axios.get('/api/servers'),
|
||||||
|
axios.get('/api/servers/availability'),
|
||||||
axios.get('/api/s3/last-modified')
|
axios.get('/api/s3/last-modified')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -92,7 +93,8 @@ function Dashboard() {
|
|||||||
const ipRangesRes = results[1];
|
const ipRangesRes = results[1];
|
||||||
const asnsRes = results[2];
|
const asnsRes = results[2];
|
||||||
const serversRes = results[3];
|
const serversRes = results[3];
|
||||||
const s3Res = results[4];
|
const availabilityRes = results[4];
|
||||||
|
const s3Res = results[5];
|
||||||
|
|
||||||
// Получаем данные серверов для подсчета дополнительной статистики
|
// Получаем данные серверов для подсчета дополнительной статистики
|
||||||
const domains = domainsRes.status === 'fulfilled' ? domainsRes.value.data : [];
|
const domains = domainsRes.status === 'fulfilled' ? domainsRes.value.data : [];
|
||||||
@@ -102,7 +104,7 @@ function Dashboard() {
|
|||||||
setRaw({ domains, ipRanges, asns, servers });
|
setRaw({ domains, ipRanges, asns, servers });
|
||||||
const countries = new Set(servers.map(server => server.country).filter(Boolean));
|
const countries = new Set(servers.map(server => server.country).filter(Boolean));
|
||||||
const providers = new Set(servers.map(server => server.provider).filter(Boolean));
|
const providers = new Set(servers.map(server => server.provider).filter(Boolean));
|
||||||
const onlineServers = servers.filter(server => server.status === 'Онлайн').length;
|
const onlineServers = availabilityRes.status === 'fulfilled' ? (availabilityRes.value.data?.online || 0) : (servers.filter(server => server.status === 'Онлайн').length);
|
||||||
|
|
||||||
// Получаем дату последнего обновления
|
// Получаем дату последнего обновления
|
||||||
// Новый формат: объект с ключами { domainsNew, asns, servers, filters, ipRanges }
|
// Новый формат: объект с ключами { domainsNew, asns, servers, filters, ipRanges }
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconServer,
|
IconServer,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconLink
|
IconLink,
|
||||||
|
IconFilter,
|
||||||
|
IconMapPin
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import GraphView from './GraphView';
|
import GraphView from './GraphView';
|
||||||
|
|
||||||
@@ -66,6 +68,11 @@ function ServerManager() {
|
|||||||
type: 'routes',
|
type: 'routes',
|
||||||
version: 'v4.rsc'
|
version: 'v4.rsc'
|
||||||
});
|
});
|
||||||
|
const presets = {
|
||||||
|
default: { baseUrl: 'https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo', cloudflare_gateway: 'SWE-IHOR', bunny_gateway: 'SWE-IHOR', fastly_gateway: 'SWE-IHOR', telegram_gateway: '94.142.140.1', hetzner_gateway: '94.142.140.1', type: 'routes', version: 'v4.rsc' },
|
||||||
|
cloudflareSWE: { baseUrl: 'https://cf.example/api', cloudflare_gateway: 'SWE-IHOR', bunny_gateway: '', fastly_gateway: '', telegram_gateway: '', hetzner_gateway: '', type: 'routes', version: 'v4.rsc' },
|
||||||
|
hetznerDE: { baseUrl: 'https://hetzner.example/api', cloudflare_gateway: '', bunny_gateway: '', fastly_gateway: '', telegram_gateway: '', hetzner_gateway: '94.142.140.1', type: 'routes', version: 'v4.rsc' }
|
||||||
|
};
|
||||||
|
|
||||||
// Состояние для мониторинга сети
|
// Состояние для мониторинга сети
|
||||||
const [networkStatus, setNetworkStatus] = useState({});
|
const [networkStatus, setNetworkStatus] = useState({});
|
||||||
@@ -81,6 +88,11 @@ function ServerManager() {
|
|||||||
]);
|
]);
|
||||||
const [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
const [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
||||||
|
|
||||||
|
// Фильтры графа
|
||||||
|
const [graphTunnelFilters, setGraphTunnelFilters] = useState({ GRE: true, IPSec: true, WireGuard: true, OpenVPN: true });
|
||||||
|
const [graphCountryFilter, setGraphCountryFilter] = useState('');
|
||||||
|
const [graphProviderFilter, setGraphProviderFilter] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -207,6 +219,15 @@ function ServerManager() {
|
|||||||
return `${urlSettings.baseUrl}?${params.toString()}`;
|
return `${urlSettings.baseUrl}?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyPreset = (name) => {
|
||||||
|
const p = presets[name];
|
||||||
|
if (!p) return;
|
||||||
|
setUrlSettings(prev => ({ ...prev, ...p }));
|
||||||
|
localStorage.setItem('urlSettings', JSON.stringify({ ...urlSettings, ...p }));
|
||||||
|
setSuccess('Профиль применён');
|
||||||
|
setTimeout(() => setSuccess(''), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
// Функция резервного копирования для http
|
// Функция резервного копирования для http
|
||||||
const fallbackCopyToClipboard = (text) => {
|
const fallbackCopyToClipboard = (text) => {
|
||||||
const textArea = document.createElement('textarea');
|
const textArea = document.createElement('textarea');
|
||||||
@@ -244,9 +265,13 @@ function ServerManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exportAllLinks = () => {
|
const exportAllLinks = () => {
|
||||||
|
const filtered = servers
|
||||||
|
.filter(s => (!filterTunnel || s.tunnel === filterTunnel))
|
||||||
|
.filter(s => (!filterCountry || s.country === filterCountry))
|
||||||
|
.filter(s => (!filterProvider || s.provider === filterProvider));
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
['IP', 'DNS', 'Country', 'Provider', 'Tunnel', 'Generated URL'],
|
['IP', 'DNS', 'Country', 'Provider', 'Tunnel', 'Generated URL'],
|
||||||
...servers.map(server => [
|
...filtered.map(server => [
|
||||||
server.ip,
|
server.ip,
|
||||||
server.dns,
|
server.dns,
|
||||||
server.country,
|
server.country,
|
||||||
@@ -456,6 +481,17 @@ function ServerManager() {
|
|||||||
<span className="me-2 d-flex align-items-center"><IconPlus className="icon" /></span>
|
<span className="me-2 d-flex align-items-center"><IconPlus className="icon" /></span>
|
||||||
<span className="fw-bold">Добавить</span>
|
<span className="fw-bold">Добавить</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* Пресеты генератора ссылок */}
|
||||||
|
<div className="dropdown">
|
||||||
|
<button className="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
|
Профили ссылок
|
||||||
|
</button>
|
||||||
|
<div className="dropdown-menu">
|
||||||
|
{Object.keys(presets).map(key => (
|
||||||
|
<button key={key} className="dropdown-item" type="button" onClick={() => applyPreset(key)}>{key}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-primary d-flex align-items-center"
|
className="btn btn-outline-primary d-flex align-items-center"
|
||||||
@@ -705,7 +741,32 @@ function ServerManager() {
|
|||||||
<div className="text-muted small">Поддержка: GRE, IPSec, WireGuard, OpenVPN</div>
|
<div className="text-muted small">Поддержка: GRE, IPSec, WireGuard, OpenVPN</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<GraphView servers={servers} connections={connections} />
|
<div className="mb-3 d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<span className="text-muted small d-flex align-items-center"><IconFilter size={16} className="me-1" />Фильтры графа:</span>
|
||||||
|
<div className="btn-group btn-group-sm" role="group" aria-label="Туннели">
|
||||||
|
{['GRE','IPSec','WireGuard','OpenVPN'].map(t => (
|
||||||
|
<button key={t} className={`btn ${graphTunnelFilters[t] ? 'btn-primary' : 'btn-outline-primary'}`} onClick={() => setGraphTunnelFilters(prev => ({...prev, [t]: !prev[t]}))}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center ms-2">
|
||||||
|
<IconMapPin size={16} className="me-1 text-muted" />
|
||||||
|
<select className="form-select form-select-sm" value={graphCountryFilter} onChange={e => setGraphCountryFilter(e.target.value)}>
|
||||||
|
<option value="">Все страны</option>
|
||||||
|
{Array.from(new Set(servers.map(s => s.country).filter(Boolean))).map(c => (<option key={c} value={c}>{c}</option>))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconServer size={16} className="me-1 text-muted" />
|
||||||
|
<select className="form-select form-select-sm" value={graphProviderFilter} onChange={e => setGraphProviderFilter(e.target.value)}>
|
||||||
|
<option value="">Все провайдеры</option>
|
||||||
|
{Array.from(new Set(servers.map(s => s.provider).filter(Boolean))).map(p => (<option key={p} value={p}>{p}</option>))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GraphView
|
||||||
|
servers={servers.filter(s => (!graphCountryFilter || s.country === graphCountryFilter) && (!graphProviderFilter || s.provider === graphProviderFilter))}
|
||||||
|
connections={connections.filter(c => graphTunnelFilters[c.tunnelType])}
|
||||||
|
/>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||||
<div className="d-flex align-items-center gap-2"><span style={{display:'inline-block',width:24,height:4,background:'#206bc4'}}></span><span className="text-muted small">GRE</span></div>
|
<div className="d-flex align-items-center gap-2"><span style={{display:'inline-block',width:24,height:4,background:'#206bc4'}}></span><span className="text-muted small">GRE</span></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user