diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b66285c..6ee992b 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,13 +11,12 @@
"@tabler/core": "^1.3.2",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.56.2",
+ "@tanstack/react-virtual": "^3.10.8",
"@xyflow/react": "^12.3.4",
"axios": "^1.10.0",
- "html-to-image": "^1.11.11",
"react": "^19.1.0",
"react-dom": "^19.1.0",
- "react-router-dom": "^6.30.1",
- "react-sigma": "^1.2.35"
+ "react-router-dom": "^6.30.1"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
@@ -28,6 +27,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
+ "typescript": "^5.7.2",
"vite": "^7.0.0"
},
"optionalDependencies": {
@@ -660,6 +660,33 @@
"react": "^18 || ^19"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
+ "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
+ "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"dev": true,
@@ -1689,12 +1716,6 @@
"node": ">= 0.4"
}
},
- "node_modules/html-to-image": {
- "version": "1.11.13",
- "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
- "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
- "license": "MIT"
- },
"node_modules/ignore": {
"version": "5.3.2",
"dev": true,
@@ -2108,19 +2129,6 @@
"react-dom": ">=16.8"
}
},
- "node_modules/react-sigma": {
- "version": "1.2.35",
- "resolved": "https://registry.npmjs.org/react-sigma/-/react-sigma-1.2.35.tgz",
- "integrity": "sha512-amjKjaQusefDp8dD9er3wE/lfubSQT5fJSRF4SsS0LjxRue4bpSapAQ1WpmSM6nQEgawbrKel+bQBmatl7MAIw==",
- "license": "MIT",
- "engines": {
- "node": ">=8.0"
- },
- "peerDependencies": {
- "react": ">=15.3",
- "react-dom": ">=15.3"
- }
- },
"node_modules/resolve-from": {
"version": "4.0.0",
"dev": true,
@@ -2216,6 +2224,20 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"dev": true,
diff --git a/frontend/src/components/server/ServerCard.jsx b/frontend/src/components/server/ServerCard.jsx
index 9c55cff..7139a33 100644
--- a/frontend/src/components/server/ServerCard.jsx
+++ b/frontend/src/components/server/ServerCard.jsx
@@ -1,6 +1,5 @@
import {
IconServer,
- IconWorld,
IconEdit,
IconTrash,
IconLink,
@@ -8,18 +7,27 @@ import {
IconChevronDown,
IconChevronUp,
IconNetwork,
- IconMapPin,
IconCheck,
- IconSquare,
- IconSquareCheck
+ IconCircleCheck,
+ IconExternalLink,
+ IconRefresh
} from '@tabler/icons-react';
import { useState } from 'react';
// Преобразование кода страны в emoji-флаг
function countryToFlag(isoCode) {
if (!isoCode) return '';
- return isoCode
- .toUpperCase()
+ const codeMap = {
+ 'SWE': 'SE',
+ 'RUS': 'RU',
+ 'USA': 'US',
+ 'GER': 'DE',
+ 'FIN': 'FI',
+ 'NLD': 'NL'
+ };
+ const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
+ if (!code || code.length !== 2) return isoCode;
+ return code
.replace(/./g, char =>
String.fromCodePoint(127397 + char.charCodeAt())
);
@@ -32,147 +40,279 @@ export default function ServerCard({
onEdit,
onDelete,
onGenerateLink,
- onQuickCopy,
- compact = false
+ onQuickCopy
}) {
const [expanded, setExpanded] = useState(false);
const isExit = server.type === 'exit';
- const primaryGateway = server.gateways?.find(g => g.primary)?.name || server.gateway || '—';
+ const primaryGateway = server.gateways?.find(g => g.primary)?.name || server.gateway || '';
+ const connectionsCount = server.gateways?.length || 0;
+ const shortName = server.dns?.split('.')[0] || server.ip?.split('.').slice(0, 2).join('.');
return (
-
- {/* Верхняя строка: checkbox + IP + тип + actions */}
-
-
- {/* Чекбокс выбора */}
-
+ {/* Основная строка - всё в одну линию */}
+
+ {/* Статус онлайн */}
+
- {/* IP адрес */}
-
- {server.ip}
-
+ {/* Чекбокс выбора */}
+
- {/* Тип */}
-
- {isExit ? 'Exit' : 'Jumphost'}
-
-
- {/* Страна */}
-
- {countryToFlag(server.country)} {server.country}
-
-
-
- {/* Действия */}
-
-
-
-
-
-
-
+ {/* Количество соединений */}
+
+
+ {connectionsCount}
- {/* Основная информация */}
-
-
-
- {server.dns || '—'}
-
-
-
- {server.provider}
-
-
-
- {server.tunnel}
-
-
-
- {primaryGateway}
-
+ {/* Флаг */}
+
+ {countryToFlag(server.country)}
+
+
+ {/* Название */}
+
+ {shortName}
+
+
+ {/* Тип */}
+
+ {isExit ? 'EXIT' : 'JUMP'}
+
+
+ {/* Провайдер */}
+
+ {server.provider}
+
+
+ {/* Туннель */}
+
+ {server.tunnel}
+
+
+ {/* DNS */}
+
+
+
+ {server.dns}
+
- {/* Расширенная информация */}
- {expanded && (
-
-
-
-
Внешний IP
-
{server.extIp || '—'}
-
-
-
Внутренний IP
-
{server.internalIp || '—'}
-
- {server.gateways?.length > 0 && (
-
-
Все шлюзы
-
- {server.gateways.map((gw, idx) => (
-
- {gw.name}{gw.ip ? ` (${gw.ip})` : ''}
-
- ))}
-
-
- )}
-
-
+ {/* Gateway */}
+ {primaryGateway && (
+
+
+ {primaryGateway}
+
)}
+
+ {/* Действия */}
+
+
+
+
+
+
+
+
+ {/* Расширенная информация */}
+ {expanded && (
+
+
+
+
IP адрес
+
{server.ip}
+
+
+
Внешний IP
+
{server.extIp || '—'}
+
+
+
Внутренний IP
+
{server.internalIp || '—'}
+
+
+
Страна
+
{countryToFlag(server.country)} {server.country}
+
+ {server.gateways?.length > 0 && (
+
+
+ Шлюзы ({server.gateways.length})
+
+
+ {server.gateways.map((gw, idx) => (
+
+ {gw.primary && }
+ {gw.name}
+ {gw.ip && ({gw.ip})}
+
+ ))}
+
+
+ )}
+
+
+ )}
);
}
diff --git a/frontend/src/components/server/ServerSidebar.jsx b/frontend/src/components/server/ServerSidebar.jsx
index be59f75..edcc282 100644
--- a/frontend/src/components/server/ServerSidebar.jsx
+++ b/frontend/src/components/server/ServerSidebar.jsx
@@ -6,18 +6,26 @@ import {
IconNetwork,
IconChevronDown,
IconChevronRight,
- IconCheck,
IconX,
IconSearch,
IconFilter,
- IconRefresh
+ IconDatabase
} from '@tabler/icons-react';
// Преобразование кода страны в emoji-флаг
function countryToFlag(isoCode) {
if (!isoCode) return '';
- return isoCode
- .toUpperCase()
+ const codeMap = {
+ 'SWE': 'SE',
+ 'RUS': 'RU',
+ 'USA': 'US',
+ 'GER': 'DE',
+ 'FIN': 'FI',
+ 'NLD': 'NL'
+ };
+ const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
+ if (!code || code.length !== 2) return isoCode;
+ return code
.replace(/./g, char =>
String.fromCodePoint(127397 + char.charCodeAt())
);
@@ -76,220 +84,340 @@ export default function ServerSidebar({
};
return (
-
- {/* Заголовок с кнопкой сброса */}
-
+
+ {/* Заголовок */}
+
-
+
Фильтры
{activeFiltersCount > 0 && (
- {activeFiltersCount}
+
+ {activeFiltersCount}
+
)}
{activeFiltersCount > 0 && (
)}
{/* Поиск */}
-
+
+
+ {/* Скроллируемый контент */}
+
+ {/* Секция: Страны */}
+
+
toggleSection('country')}
+ style={{
+ background: 'rgba(0,0,0,0.02)',
+ borderBottom: '1px solid rgba(0,0,0,0.04)'
+ }}
+ >
+
+
+ Страны
+
+ {expandedSections.country ?
:
}
+
+ {expandedSections.country && (
+
+ {Object.entries(stats.byCountry)
+ .sort((a, b) => b[1] - a[1])
+ .map(([country, count]) => (
+
+ ))}
+
+ )}
+
+
+ {/* Секция: Провайдеры */}
+
+
toggleSection('provider')}
+ style={{
+ background: 'rgba(0,0,0,0.02)',
+ borderBottom: '1px solid rgba(0,0,0,0.04)'
+ }}
+ >
+
+
+ Провайдеры
+
+ {expandedSections.provider ?
:
}
+
+ {expandedSections.provider && (
+
+ {Object.entries(stats.byProvider)
+ .sort((a, b) => b[1] - a[1])
+ .map(([provider, count]) => (
+
+ ))}
+
+ )}
+
+
+ {/* Секция: Типы */}
+
+
toggleSection('type')}
+ style={{
+ background: 'rgba(0,0,0,0.02)',
+ borderBottom: '1px solid rgba(0,0,0,0.04)'
+ }}
+ >
+
+
+ Тип сервера
+
+ {expandedSections.type ?
:
}
+
+ {expandedSections.type && (
+
+ {Object.entries(stats.byType)
+ .sort((a, b) => b[1] - a[1])
+ .map(([type, count]) => (
+
+ ))}
+
+ )}
+
+
+ {/* Секция: Туннели */}
+
+
toggleSection('tunnel')}
+ style={{
+ background: 'rgba(0,0,0,0.02)',
+ borderBottom: '1px solid rgba(0,0,0,0.04)'
+ }}
+ >
+
+
+ Туннель
+
+ {expandedSections.tunnel ?
:
}
+
+ {expandedSections.tunnel && (
+
+ {Object.entries(stats.byTunnel)
+ .sort((a, b) => b[1] - a[1])
+ .map(([tunnel, count]) => (
+
+ ))}
+
)}
- {/* Секция: Страны */}
-
-
toggleSection('country')}
- style={{ background: 'var(--tblr-bg-surface-secondary)' }}
- >
-
-
- Страны
-
- {expandedSections.country ?
:
}
+ {/* Статистика внизу */}
+
+
+
+ Статистика
- {expandedSections.country && (
-
- {Object.entries(stats.byCountry)
- .sort((a, b) => b[1] - a[1])
- .map(([country, count]) => (
-
- ))}
-
- )}
-
-
- {/* Секция: Провайдеры */}
-
-
toggleSection('provider')}
- style={{ background: 'var(--tblr-bg-surface-secondary)' }}
- >
-
-
- Провайдеры
-
- {expandedSections.provider ?
:
}
+
+ Всего серверов
+ {servers.length}
- {expandedSections.provider && (
-
- {Object.entries(stats.byProvider)
- .sort((a, b) => b[1] - a[1])
- .map(([provider, count]) => (
-
- ))}
-
- )}
-
-
- {/* Секция: Типы серверов */}
-
-
toggleSection('type')}
- style={{ background: 'var(--tblr-bg-surface-secondary)' }}
- >
-
-
- Тип сервера
-
- {expandedSections.type ?
:
}
+
+ Выходных нод
+ {stats.byType['exit'] || 0}
- {expandedSections.type && (
-
- {Object.entries(stats.byType)
- .sort((a, b) => b[1] - a[1])
- .map(([type, count]) => (
-
- ))}
-
- )}
-
-
- {/* Секция: Туннели */}
-
-
toggleSection('tunnel')}
- style={{ background: 'var(--tblr-bg-surface-secondary)' }}
- >
-
-
- Тип туннеля
-
- {expandedSections.tunnel ?
:
}
-
- {expandedSections.tunnel && (
-
- {Object.entries(stats.byTunnel)
- .sort((a, b) => b[1] - a[1])
- .map(([tunnel, count]) => (
-
- ))}
-
- )}
-
-
- {/* Статистика */}
-
-
-
- Всего серверов:
- {servers.length}
-
-
- Выходных нод:
- {stats.byType['exit'] || 0}
-
-
- Jumphosts:
- {stats.byType['jumphost'] || 0}
-
+
+ Jumphosts
+ {stats.byType['jumphost'] || 0}