mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
feat(remnawave): GeoCheck ноды в админке — запуск проверки и просмотр отчёта
Кнопка в карточке ноды открывает модалку: выбор маршрута (по умолчанию / IP-адрес / интерфейс) с подсказками из данных ноды, ожидание, затем отчёт картинкой либо тем же отчётом в JSON. Есть копирование JSON, скачивание SVG, повтор проверки и полноэкранный режим. Кнопка показывается только для узлов 3.3.0+ (versions.node) и только для подключённых — иначе админ упирался бы в ошибку панели. Отчёт вставляется как <img src="data:...">, а не сырым SVG в разметку: он несёт встроенный моноширинный шрифт и <style>, на которых держится выравнивание колонок, и санитайзер их выбрасывает. В <img> SVG рисуется в изолированном контексте без скриптов и внешних запросов — и безопаснее, и вернее по виду. Зум и панорамирование — react-zoom-pan-pinch с той же конфигурацией, что в самой панели Remnawave. Своя реализация обязательна: index.html задаёт user-scalable=no, поэтому браузерного пинча нет ни в мобильном вебе, ни в Mini App. Колесо намеренно не масштабирует, иначе прокрутка над отчётом превращается в зум. В отличие от панели стартовый масштаб считается от читаемой ширины: на телефоне «вписано по ширине» превращает отчёт в серую рябь. На 390px это 2.42x с панорамированием, на десктопе — 1x. При смене ширины (полный экран, поворот) масштаб пересчитывается. Опрос задачи с потолком в три минуты — иначе забытая открытой модалка опрашивала бы панель бесконечно. Пока проверка идёт, модалка не закрывается: задача уже поставлена, терять её результат молча хуже. Заодно в AdminRemnawave.tsx осели автоправки biome (import type, Math.pow -> **) — файл всё равно правился, они механические. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -59,6 +59,7 @@
|
|||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
|
"react-zoom-pan-pinch": "^4.0.4",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"sigma": "^3.0.2",
|
"sigma": "^3.0.2",
|
||||||
"simplex-noise": "^4.0.3",
|
"simplex-noise": "^4.0.3",
|
||||||
@@ -6211,6 +6212,20 @@
|
|||||||
"react-dom": ">=19.0.0"
|
"react-dom": ">=19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-zoom-pan-pinch": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-P0D7lfNHyJCNuUozoVdt0WNWcQ34ZbbD71B8pb+UtF7Th8MKBnxYiPApDfPZZmr+Gk0l0WmGTXL8R36JirHcQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8",
|
||||||
|
"npm": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-dom": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
|
"react-zoom-pan-pinch": "^4.0.4",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"sigma": "^3.0.2",
|
"sigma": "^3.0.2",
|
||||||
"simplex-noise": "^4.0.3",
|
"simplex-noise": "^4.0.3",
|
||||||
|
|||||||
@@ -112,6 +112,23 @@ export interface SystemStatsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nodes
|
// Nodes
|
||||||
|
export type NodeIpStatus =
|
||||||
|
| 'INBOUND'
|
||||||
|
| 'OUTBOUND'
|
||||||
|
| 'MANAGEMENT'
|
||||||
|
| 'TRANSIT'
|
||||||
|
| 'MONITORING'
|
||||||
|
| 'RESERVE'
|
||||||
|
| 'BLOCKED'
|
||||||
|
| 'FLAGGED'
|
||||||
|
| 'DEPRECATED'
|
||||||
|
| 'UNKNOWN';
|
||||||
|
|
||||||
|
export interface NodeIpAddress {
|
||||||
|
ip: string;
|
||||||
|
status: NodeIpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeInfo {
|
export interface NodeInfo {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -165,6 +182,8 @@ export interface NodeInfo {
|
|||||||
};
|
};
|
||||||
} | null;
|
} | null;
|
||||||
active_plugin_uuid?: string;
|
active_plugin_uuid?: string;
|
||||||
|
/** Адреса узла из панели — варианты исходного адреса для GeoCheck. */
|
||||||
|
ips?: NodeIpAddress[];
|
||||||
config_profile?: {
|
config_profile?: {
|
||||||
active_config_profile_uuid: string | null;
|
active_config_profile_uuid: string | null;
|
||||||
active_inbounds: Array<{
|
active_inbounds: Array<{
|
||||||
@@ -206,6 +225,40 @@ export interface NodeActionResponse {
|
|||||||
is_disabled?: boolean;
|
is_disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GeoCheck (Remnawave 3.3.0)
|
||||||
|
/** С какого маршрута гнать проверку: пусто — маршрут узла по умолчанию. */
|
||||||
|
export interface GeoCheckRequest {
|
||||||
|
ip?: string;
|
||||||
|
interface?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoCheckStartResponse {
|
||||||
|
job_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SVG-отчёт в base64, готовый для data: URL. */
|
||||||
|
export interface GeoCheckImage {
|
||||||
|
format: string;
|
||||||
|
media_type: string;
|
||||||
|
encoding: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoCheckResult {
|
||||||
|
success: boolean;
|
||||||
|
node_uuid?: string | null;
|
||||||
|
image?: GeoCheckImage | null;
|
||||||
|
raw_report?: Record<string, unknown> | null;
|
||||||
|
message?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoCheckJobResponse {
|
||||||
|
job_id: string;
|
||||||
|
is_completed: boolean;
|
||||||
|
is_failed: boolean;
|
||||||
|
result?: GeoCheckResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Realtime Traffic
|
// Realtime Traffic
|
||||||
export interface InboundTraffic {
|
export interface InboundTraffic {
|
||||||
tag: string;
|
tag: string;
|
||||||
@@ -403,6 +456,21 @@ export const adminRemnawaveApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Ставит GeoCheck ноды в очередь; результат забирается по job_id. */
|
||||||
|
startNodeGeoCheck: async (
|
||||||
|
uuid: string,
|
||||||
|
body: GeoCheckRequest = {},
|
||||||
|
): Promise<GeoCheckStartResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/geocheck`, body);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Статус задачи GeoCheck — нода отвечает до минуты. */
|
||||||
|
getGeoCheckJob: async (jobId: string): Promise<GeoCheckJobResponse> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/remnawave/geocheck/${jobId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
restartAllNodes: async (): Promise<NodeActionResponse> => {
|
restartAllNodes: async (): Promise<NodeActionResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all');
|
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all');
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
120
src/components/admin/remnawave/GeoCheckImageViewer.tsx
Normal file
120
src/components/admin/remnawave/GeoCheckImageViewer.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
|
||||||
|
import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ширина в CSS-пикселях, при которой моноширинный отчёт ещё читается.
|
||||||
|
* Отчёт всегда заметно шире телефона, поэтому «вписать по ширине» на узком
|
||||||
|
* экране превращает его в серую рябь. Стартуем с масштаба, дающего примерно
|
||||||
|
* такую ширину, а вписать целиком всегда можно кнопкой сброса и жестом.
|
||||||
|
*/
|
||||||
|
const READABLE_WIDTH = 760;
|
||||||
|
|
||||||
|
/** Масштаб 1 — отчёт вписан в ширину области; ниже опускаться незачем. */
|
||||||
|
const SCALE_MIN = 1;
|
||||||
|
const SCALE_MAX = 8;
|
||||||
|
|
||||||
|
interface GeoCheckImageViewerProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
/** Смена режима меняет доступную ширину — просмотрщик пересобирается. */
|
||||||
|
fullscreen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Просмотр широкого SVG-отчёта: зум, перетаскивание, пинч, двойной тап.
|
||||||
|
*
|
||||||
|
* Зум свой, а не браузерный: в `index.html` приложения стоит
|
||||||
|
* `user-scalable=no`, поэтому нативного пинча нет ни в мобильном вебе, ни в
|
||||||
|
* Mini App. Библиотека берёт на себя все жесты и работает одинаково от мыши,
|
||||||
|
* пальца и трекпада — так же это сделано в самой панели Remnawave.
|
||||||
|
*
|
||||||
|
* Колесо намеренно не масштабирует (`wheelDisabled`): иначе обычная прокрутка
|
||||||
|
* над отчётом превращалась бы в зум.
|
||||||
|
*/
|
||||||
|
export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
|
||||||
|
observer.observe(host);
|
||||||
|
setWidth(host.clientWidth);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Масштаб 1 = вписано по ширине. На широком экране этого достаточно, на
|
||||||
|
// узком — поднимаем до читаемого и позволяем панорамировать.
|
||||||
|
const initialScale = width > 0 ? Math.min(SCALE_MAX, Math.max(1, READABLE_WIDTH / width)) : 1;
|
||||||
|
|
||||||
|
const controlButton =
|
||||||
|
'flex h-9 w-9 items-center justify-center rounded-lg text-dark-200 transition-colors hover:bg-dark-100/10 active:scale-95';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={hostRef} className="relative h-full">
|
||||||
|
{width > 0 && (
|
||||||
|
<TransformWrapper
|
||||||
|
// Пересобираем, когда меняется нужный стартовый масштаб: полный экран,
|
||||||
|
// поворот телефона, разворот окна. Ключ огрублён до десятых — пока
|
||||||
|
// отчёт и так вписывается (масштаб 1), перетаскивание окна мышью
|
||||||
|
// ничего не сбрасывает.
|
||||||
|
key={`${fullscreen}-${Math.round(initialScale * 10)}`}
|
||||||
|
centerOnInit={false}
|
||||||
|
disablePadding
|
||||||
|
doubleClick={{ mode: 'toggle' }}
|
||||||
|
initialScale={initialScale}
|
||||||
|
maxScale={SCALE_MAX}
|
||||||
|
minScale={SCALE_MIN}
|
||||||
|
trackPadPanning={{ disabled: false }}
|
||||||
|
wheel={{ wheelDisabled: true }}
|
||||||
|
>
|
||||||
|
{({ resetTransform, zoomIn, zoomOut }) => (
|
||||||
|
<>
|
||||||
|
<TransformComponent
|
||||||
|
contentStyle={{ width: '100%' }}
|
||||||
|
// touch-action: none — все жесты обрабатывает библиотека,
|
||||||
|
// иначе браузер перехватит их под прокрутку.
|
||||||
|
wrapperClass="!h-full !w-full cursor-grab touch-none active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<img alt={alt} className="block w-full select-none" draggable={false} src={src} />
|
||||||
|
</TransformComponent>
|
||||||
|
|
||||||
|
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-0.5 rounded-xl border border-dark-100/10 bg-dark-950/70 p-1 shadow-lg backdrop-blur-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomOut()}
|
||||||
|
className={controlButton}
|
||||||
|
aria-label={t('admin.remnawave.geoCheck.zoomOut', 'Zoom out')}
|
||||||
|
>
|
||||||
|
<MinusIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomIn()}
|
||||||
|
className={controlButton}
|
||||||
|
aria-label={t('admin.remnawave.geoCheck.zoomIn', 'Zoom in')}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span aria-hidden className="mx-0.5 h-5 w-px bg-dark-100/15" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => resetTransform()}
|
||||||
|
className={controlButton}
|
||||||
|
aria-label={t('admin.remnawave.geoCheck.zoomReset', 'Reset zoom')}
|
||||||
|
>
|
||||||
|
<ResetIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TransformWrapper>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/admin/remnawave/GeoCheckModal.tsx
Normal file
196
src/components/admin/remnawave/GeoCheckModal.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { NodeInfo } from '@/api/adminRemnawave';
|
||||||
|
import { GeoCheckIcon, PlayIcon, WarningIcon, XCloseIcon } from '@/components/icons';
|
||||||
|
import { Spinner } from '@/components/ui/Spinner';
|
||||||
|
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { GeoCheckReport } from './GeoCheckReport';
|
||||||
|
import { buildGeoCheckRequest, isRouteReady, type GeoCheckRouteMode } from './geoCheckRoute';
|
||||||
|
import { GeoCheckSetup } from './GeoCheckSetup';
|
||||||
|
import { useGeoCheckJob } from './useGeoCheckJob';
|
||||||
|
|
||||||
|
interface GeoCheckModalProps {
|
||||||
|
node: NodeInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeoCheck ноды: выбор маршрута → ожидание → отчёт.
|
||||||
|
*
|
||||||
|
* Полноэкранный просмотр — режим самой модалки, а не отдельный оверлей:
|
||||||
|
* оверлей поверх портала выпал бы из ловушки фокуса, и его кнопка закрытия
|
||||||
|
* стала бы недоступна с клавиатуры.
|
||||||
|
*/
|
||||||
|
export function GeoCheckModal({ node, onClose }: GeoCheckModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [mode, setMode] = useState<GeoCheckRouteMode>('default');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const job = useGeoCheckJob(node.uuid);
|
||||||
|
const isRunning = job.phase === 'running';
|
||||||
|
|
||||||
|
// Пока идёт проверка, закрывать по Escape нельзя: задача уже поставлена
|
||||||
|
// в панель, и молча потерять её результат — хуже, чем подождать.
|
||||||
|
const dialogRef = useFocusTrap<HTMLDivElement>(true, {
|
||||||
|
onEscape: isRunning ? undefined : fullscreen ? () => setFullscreen(false) : onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (job.phase !== 'done') setFullscreen(false);
|
||||||
|
}, [job.phase]);
|
||||||
|
|
||||||
|
const canStart = isRouteReady(mode, value);
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
if (!canStart) return;
|
||||||
|
job.start(buildGeoCheckRequest(mode, value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = () => {
|
||||||
|
if (!isRunning) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorText =
|
||||||
|
job.error?.kind === 'timeout'
|
||||||
|
? t(
|
||||||
|
'admin.remnawave.geoCheck.error.timeout',
|
||||||
|
'The node did not answer in time. Try running the check again.',
|
||||||
|
)
|
||||||
|
: (job.error?.message ??
|
||||||
|
t('admin.remnawave.geoCheck.error.generic', 'The check could not be completed.'));
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
paddingTop: fullscreen ? 0 : 'max(1rem, env(safe-area-inset-top))',
|
||||||
|
paddingBottom: fullscreen ? 0 : 'max(1rem, env(safe-area-inset-bottom))',
|
||||||
|
paddingLeft: fullscreen ? 0 : 'max(1rem, env(safe-area-inset-left))',
|
||||||
|
paddingRight: fullscreen ? 0 : 'max(1rem, env(safe-area-inset-right))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-dark-950/80 backdrop-blur-sm"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="geocheck-modal-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full flex-col border border-dark-700 bg-dark-900 shadow-2xl',
|
||||||
|
fullscreen
|
||||||
|
? 'h-full max-h-none max-w-none rounded-none p-3'
|
||||||
|
: 'max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-2xl p-5',
|
||||||
|
// Отчёт заметно шире формы запуска — под него модалка расширяется.
|
||||||
|
!fullscreen && (job.phase === 'done' ? 'max-w-4xl' : 'max-w-2xl'),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent-500/15 text-accent-400">
|
||||||
|
<GeoCheckIcon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 id="geocheck-modal-title" className="text-lg font-semibold text-dark-100">
|
||||||
|
{t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
||||||
|
</h3>
|
||||||
|
<p className="truncate text-xs text-dark-400">{node.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isRunning && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close', 'Close')}
|
||||||
|
className="-mr-1.5 flex min-h-[44px] min-w-[44px] shrink-0 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
<XCloseIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.phase === 'idle' && (
|
||||||
|
<>
|
||||||
|
<GeoCheckSetup
|
||||||
|
node={node}
|
||||||
|
mode={mode}
|
||||||
|
value={value}
|
||||||
|
onModeChange={(next) => {
|
||||||
|
setMode(next);
|
||||||
|
setValue('');
|
||||||
|
}}
|
||||||
|
onValueChange={setValue}
|
||||||
|
/>
|
||||||
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
<button type="button" onClick={onClose} className="btn-ghost">
|
||||||
|
{t('common.close', 'Close')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleStart}
|
||||||
|
disabled={!canStart}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
<PlayIcon className="h-4 w-4" />
|
||||||
|
{t('admin.remnawave.geoCheck.start', 'Run check')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRunning && (
|
||||||
|
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
|
||||||
|
<Spinner className="h-10 w-10" />
|
||||||
|
<p className="text-sm font-medium text-dark-100">
|
||||||
|
{t('admin.remnawave.geoCheck.running', 'Running the geo check')}
|
||||||
|
</p>
|
||||||
|
<p className="max-w-sm text-xs text-dark-400">
|
||||||
|
{t(
|
||||||
|
'admin.remnawave.geoCheck.runningHint',
|
||||||
|
'The node is testing its connection — this usually takes up to a minute.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.phase === 'error' && (
|
||||||
|
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-error-500/15 text-error-400">
|
||||||
|
<WarningIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium text-dark-100">
|
||||||
|
{t('admin.remnawave.geoCheck.error.title', 'Check failed')}
|
||||||
|
</p>
|
||||||
|
<p className="max-w-sm break-words text-xs text-dark-400">{errorText}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<button type="button" onClick={job.reset} className="btn-secondary">
|
||||||
|
{t('admin.remnawave.geoCheck.changeRoute', 'Change route')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={job.retry} className="btn-primary">
|
||||||
|
{t('admin.remnawave.geoCheck.rerun', 'Run again')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.phase === 'done' && job.result && (
|
||||||
|
<GeoCheckReport
|
||||||
|
result={job.result}
|
||||||
|
nodeName={node.name}
|
||||||
|
fullscreen={fullscreen}
|
||||||
|
onToggleFullscreen={() => setFullscreen((v) => !v)}
|
||||||
|
onRerun={job.retry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/components/admin/remnawave/GeoCheckReport.tsx
Normal file
208
src/components/admin/remnawave/GeoCheckReport.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { GeoCheckResult } from '@/api/adminRemnawave';
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
CodeIcon,
|
||||||
|
CollapseIcon,
|
||||||
|
CopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
ExpandIcon,
|
||||||
|
EyeIcon,
|
||||||
|
RefreshIcon,
|
||||||
|
} from '@/components/icons';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
|
import { GeoCheckImageViewer } from './GeoCheckImageViewer';
|
||||||
|
|
||||||
|
type ReportTab = 'report' | 'json';
|
||||||
|
|
||||||
|
const TOOLBAR_BUTTON =
|
||||||
|
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40';
|
||||||
|
|
||||||
|
interface GeoCheckReportProps {
|
||||||
|
result: GeoCheckResult;
|
||||||
|
nodeName: string;
|
||||||
|
fullscreen: boolean;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onRerun: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Готовый отчёт GeoCheck: картинка либо тот же отчёт в JSON.
|
||||||
|
*
|
||||||
|
* Картинка вставляется как `<img src="data:...">`, а не сырым SVG в разметку:
|
||||||
|
* отчёт несёт встроенный моноширинный шрифт и `<style>`, на которых держится
|
||||||
|
* выравнивание колонок, — санитайзер их выбросит и таблицы поедут. В `<img>`
|
||||||
|
* SVG рисуется в изолированном контексте без скриптов и внешних запросов, то
|
||||||
|
* есть это и безопаснее, и вернее по виду.
|
||||||
|
*/
|
||||||
|
export function GeoCheckReport({
|
||||||
|
result,
|
||||||
|
nodeName,
|
||||||
|
fullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onRerun,
|
||||||
|
}: GeoCheckReportProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [tab, setTab] = useState<ReportTab>('report');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const imageSrc = useMemo(() => {
|
||||||
|
const image = result.image;
|
||||||
|
if (!image?.data) return null;
|
||||||
|
return `data:${image.media_type};${image.encoding},${image.data}`;
|
||||||
|
}, [result.image]);
|
||||||
|
|
||||||
|
const reportJson = useMemo(
|
||||||
|
() => (result.raw_report ? JSON.stringify(result.raw_report, null, 2) : null),
|
||||||
|
[result.raw_report],
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageAlt = t('admin.remnawave.geoCheck.reportAlt', 'GeoCheck report for {{node}}', {
|
||||||
|
node: nodeName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!reportJson) return;
|
||||||
|
await copyToClipboard(reportJson);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const image = result.image;
|
||||||
|
if (!image?.data) return;
|
||||||
|
// Blob, а не прямая ссылка на data: URL — во встроенном webview Telegram
|
||||||
|
// скачивание по data: молча ничего не делает.
|
||||||
|
const binary = atob(image.data);
|
||||||
|
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||||
|
const url = URL.createObjectURL(new Blob([bytes], { type: image.media_type }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `geocheck-${nodeName.replace(/[^\w.-]+/g, '-').toLowerCase()}.svg`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs: Array<{ id: ReportTab; label: string; icon: ReactNode; enabled: boolean }> = [
|
||||||
|
{
|
||||||
|
id: 'report',
|
||||||
|
label: t('admin.remnawave.geoCheck.tab.report', 'Report'),
|
||||||
|
icon: <EyeIcon className="h-3.5 w-3.5" />,
|
||||||
|
enabled: Boolean(imageSrc),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'json',
|
||||||
|
label: t('admin.remnawave.geoCheck.tab.json', 'JSON'),
|
||||||
|
icon: <CodeIcon className="h-3.5 w-3.5" />,
|
||||||
|
enabled: Boolean(reportJson),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fullscreenLabel = fullscreen
|
||||||
|
? t('admin.remnawave.geoCheck.exitFullscreen', 'Exit fullscreen')
|
||||||
|
: t('admin.remnawave.geoCheck.fullscreen', 'Fullscreen');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex gap-1 rounded-xl bg-dark-800/50 p-1">
|
||||||
|
{tabs
|
||||||
|
.filter((item) => item.enabled)
|
||||||
|
.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(item.id)}
|
||||||
|
aria-pressed={tab === item.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
||||||
|
tab === item.id
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
disabled={!imageSrc}
|
||||||
|
className={TOOLBAR_BUTTON}
|
||||||
|
title={fullscreenLabel}
|
||||||
|
aria-label={fullscreenLabel}
|
||||||
|
aria-pressed={fullscreen}
|
||||||
|
>
|
||||||
|
{fullscreen ? <CollapseIcon className="h-4 w-4" /> : <ExpandIcon className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={!reportJson}
|
||||||
|
className={TOOLBAR_BUTTON}
|
||||||
|
title={t('admin.remnawave.geoCheck.copyJson', 'Copy JSON report')}
|
||||||
|
aria-label={t('admin.remnawave.geoCheck.copyJson', 'Copy JSON report')}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="h-4 w-4 text-success-400" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!imageSrc}
|
||||||
|
className={TOOLBAR_BUTTON}
|
||||||
|
title={t('admin.remnawave.geoCheck.download', 'Download SVG')}
|
||||||
|
aria-label={t('admin.remnawave.geoCheck.download', 'Download SVG')}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRerun}
|
||||||
|
className={TOOLBAR_BUTTON}
|
||||||
|
title={t('admin.remnawave.geoCheck.rerun', 'Run again')}
|
||||||
|
aria-label={t('admin.remnawave.geoCheck.rerun', 'Run again')}
|
||||||
|
>
|
||||||
|
<RefreshIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.message && (
|
||||||
|
<p className="mt-3 rounded-xl bg-warning-500/10 px-3 py-2 text-xs text-warning-400">
|
||||||
|
{result.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Высота задана явно: без неё зум-поверхности не от чего отталкиваться,
|
||||||
|
а модалка растягивалась бы под натуральный размер отчёта. */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative mt-3 overflow-hidden rounded-2xl border border-dark-700 bg-dark-950 [contain:paint]',
|
||||||
|
fullscreen ? 'min-h-0 flex-1' : 'h-[62dvh] min-h-[16rem]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab === 'report' && imageSrc && (
|
||||||
|
<GeoCheckImageViewer src={imageSrc} alt={imageAlt} fullscreen={fullscreen} />
|
||||||
|
)}
|
||||||
|
{tab === 'json' && reportJson && (
|
||||||
|
<pre className="h-full overflow-auto p-3 font-mono text-[11px] leading-relaxed text-dark-200">
|
||||||
|
{reportJson}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/components/admin/remnawave/GeoCheckSetup.tsx
Normal file
152
src/components/admin/remnawave/GeoCheckSetup.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { NodeInfo } from '@/api/adminRemnawave';
|
||||||
|
import { GlobeIcon, NetworkIcon, SparklesIcon } from '@/components/icons';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
isRouteReady,
|
||||||
|
suggestedInterfaces,
|
||||||
|
suggestedIps,
|
||||||
|
type GeoCheckRouteMode,
|
||||||
|
} from './geoCheckRoute';
|
||||||
|
|
||||||
|
interface GeoCheckSetupProps {
|
||||||
|
node: NodeInfo;
|
||||||
|
mode: GeoCheckRouteMode;
|
||||||
|
value: string;
|
||||||
|
onModeChange: (mode: GeoCheckRouteMode) => void;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбор маршрута, с которого гнать проверку: по умолчанию / IP / интерфейс. */
|
||||||
|
export function GeoCheckSetup({
|
||||||
|
node,
|
||||||
|
mode,
|
||||||
|
value,
|
||||||
|
onModeChange,
|
||||||
|
onValueChange,
|
||||||
|
}: GeoCheckSetupProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const modes: Array<{ id: GeoCheckRouteMode; label: string }> = [
|
||||||
|
{ id: 'default', label: t('admin.remnawave.geoCheck.mode.default', 'Default') },
|
||||||
|
{ id: 'ip', label: t('admin.remnawave.geoCheck.mode.ip', 'IP address') },
|
||||||
|
{ id: 'interface', label: t('admin.remnawave.geoCheck.mode.interface', 'Interface') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ips = suggestedIps(node);
|
||||||
|
const interfaces = suggestedInterfaces(node);
|
||||||
|
const suggestions = mode === 'ip' ? ips : mode === 'interface' ? interfaces : [];
|
||||||
|
|
||||||
|
const hint =
|
||||||
|
mode === 'default'
|
||||||
|
? t('admin.remnawave.geoCheck.hint.default', 'The node will use its default outbound route.')
|
||||||
|
: mode === 'ip'
|
||||||
|
? t(
|
||||||
|
'admin.remnawave.geoCheck.hint.ip',
|
||||||
|
'Pick one of the node addresses or type any address.',
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'admin.remnawave.geoCheck.hint.interface',
|
||||||
|
'Pick one of the node interfaces or type any name.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const placeholder =
|
||||||
|
mode === 'default'
|
||||||
|
? t('admin.remnawave.geoCheck.placeholder.default', 'Automatic')
|
||||||
|
: mode === 'ip'
|
||||||
|
? '1.2.3.4'
|
||||||
|
: 'eth0';
|
||||||
|
|
||||||
|
const isInvalid = mode !== 'default' && value.trim().length > 0 && !isRouteReady(mode, value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t('admin.remnawave.geoCheck.mode.legend', 'Check route')}
|
||||||
|
className="flex gap-1 rounded-xl bg-dark-800/50 p-1"
|
||||||
|
>
|
||||||
|
{modes.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === item.id}
|
||||||
|
onClick={() => onModeChange(item.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 whitespace-nowrap rounded-lg px-2 py-2 text-xs font-medium transition-colors sm:px-3 sm:text-sm',
|
||||||
|
mode === item.id
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="geocheck-route-value" className="block text-sm font-medium text-dark-200">
|
||||||
|
{modes.find((item) => item.id === mode)?.label}
|
||||||
|
</label>
|
||||||
|
<p className="mt-0.5 text-xs text-dark-400">{hint}</p>
|
||||||
|
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||||
|
{mode === 'default' ? (
|
||||||
|
<SparklesIcon className="h-4 w-4" />
|
||||||
|
) : mode === 'ip' ? (
|
||||||
|
<GlobeIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<NetworkIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="geocheck-route-value"
|
||||||
|
type="text"
|
||||||
|
inputMode={mode === 'ip' ? 'numeric' : 'text'}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={mode === 'default'}
|
||||||
|
value={mode === 'default' ? '' : value}
|
||||||
|
onChange={(e) => onValueChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-invalid={isInvalid}
|
||||||
|
className={cn(
|
||||||
|
'input pl-9 font-mono disabled:cursor-not-allowed disabled:opacity-60',
|
||||||
|
isInvalid && 'input-error',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInvalid && (
|
||||||
|
<p className="mt-1.5 text-xs text-error-400">
|
||||||
|
{mode === 'ip'
|
||||||
|
? t('admin.remnawave.geoCheck.invalidIp', 'Enter a valid IPv4 or IPv6 address')
|
||||||
|
: t('admin.remnawave.geoCheck.invalidInterface', 'Enter a valid interface name')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onValueChange(suggestion)}
|
||||||
|
className={cn(
|
||||||
|
'max-w-full truncate rounded-lg px-2 py-1 font-mono text-[11px] transition-colors',
|
||||||
|
value.trim() === suggestion
|
||||||
|
? 'bg-accent-500/20 text-accent-300'
|
||||||
|
: 'bg-dark-700/60 text-dark-300 hover:bg-dark-700 hover:text-dark-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/components/admin/remnawave/geoCheckRoute.test.ts
Normal file
134
src/components/admin/remnawave/geoCheckRoute.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { NodeInfo } from '@/api/adminRemnawave';
|
||||||
|
import {
|
||||||
|
buildGeoCheckRequest,
|
||||||
|
isRouteReady,
|
||||||
|
isValidInterfaceName,
|
||||||
|
isValidIpAddress,
|
||||||
|
suggestedInterfaces,
|
||||||
|
suggestedIps,
|
||||||
|
} from './geoCheckRoute';
|
||||||
|
|
||||||
|
function node(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||||
|
return {
|
||||||
|
uuid: 'n-1',
|
||||||
|
name: 'Germany #2',
|
||||||
|
address: '213.176.77.249',
|
||||||
|
is_connected: true,
|
||||||
|
is_disabled: false,
|
||||||
|
is_node_online: true,
|
||||||
|
is_xray_running: true,
|
||||||
|
users_online: 10,
|
||||||
|
xray_uptime: 0,
|
||||||
|
is_traffic_tracking_active: false,
|
||||||
|
consumption_multiplier: 1,
|
||||||
|
...overrides,
|
||||||
|
} as NodeInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isValidIpAddress', () => {
|
||||||
|
it.each([
|
||||||
|
'1.2.3.4',
|
||||||
|
'213.176.77.249',
|
||||||
|
'0.0.0.0',
|
||||||
|
'255.255.255.255',
|
||||||
|
'2a0b:4141:820:140d::2',
|
||||||
|
])('принимает %s', (value) => expect(isValidIpAddress(value)).toBe(true));
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
' ',
|
||||||
|
'not-an-ip',
|
||||||
|
'256.1.1.1',
|
||||||
|
'1.2.3',
|
||||||
|
'1.2.3.4/24',
|
||||||
|
'1.2.3.4 ; reboot',
|
||||||
|
])('отклоняет %s', (value) => expect(isValidIpAddress(value)).toBe(false));
|
||||||
|
|
||||||
|
it('игнорирует обрамляющие пробелы', () => {
|
||||||
|
expect(isValidIpAddress(' 1.2.3.4 ')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidInterfaceName', () => {
|
||||||
|
it.each(['eth0', 'ens3', 'wg0', 'br-lan', 'enp0s31f6'])('принимает %s', (value) =>
|
||||||
|
expect(isValidInterfaceName(value)).toBe(true));
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
'bad iface',
|
||||||
|
'eth0;reboot',
|
||||||
|
'../etc',
|
||||||
|
'-eth0',
|
||||||
|
'a'.repeat(33),
|
||||||
|
])('отклоняет %s', (value) => expect(isValidInterfaceName(value)).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRouteReady', () => {
|
||||||
|
it('режим по умолчанию не требует значения', () => {
|
||||||
|
expect(isRouteReady('default', '')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('режимы ip и interface требуют корректного значения', () => {
|
||||||
|
expect(isRouteReady('ip', '')).toBe(false);
|
||||||
|
expect(isRouteReady('ip', '1.2.3.4')).toBe(true);
|
||||||
|
expect(isRouteReady('interface', '')).toBe(false);
|
||||||
|
expect(isRouteReady('interface', 'ens3')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildGeoCheckRequest', () => {
|
||||||
|
it('по умолчанию отправляет пустое тело', () => {
|
||||||
|
expect(buildGeoCheckRequest('default', 'ens3')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отправляет ровно одно поле — иначе панель получит неоднозначный запрос', () => {
|
||||||
|
expect(buildGeoCheckRequest('ip', ' 1.2.3.4 ')).toEqual({ ip: '1.2.3.4' });
|
||||||
|
expect(buildGeoCheckRequest('interface', ' ens3 ')).toEqual({ interface: 'ens3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустое значение в режиме ip/interface означает маршрут по умолчанию', () => {
|
||||||
|
expect(buildGeoCheckRequest('ip', ' ')).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('suggestedIps', () => {
|
||||||
|
it('ставит адрес ноды первым и не дублирует его', () => {
|
||||||
|
const result = suggestedIps(
|
||||||
|
node({
|
||||||
|
ips: [
|
||||||
|
{ ip: '213.176.77.249', status: 'OUTBOUND' },
|
||||||
|
{ ip: '2a0b:4141:820:140d::2', status: 'INBOUND' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(['213.176.77.249', '2a0b:4141:820:140d::2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не предлагает заблокированные адреса', () => {
|
||||||
|
const result = suggestedIps(node({ ips: [{ ip: '10.0.0.1', status: 'BLOCKED' }] }));
|
||||||
|
expect(result).toEqual(['213.176.77.249']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('работает, когда панель не прислала ips', () => {
|
||||||
|
expect(suggestedIps(node())).toEqual(['213.176.77.249']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('suggestedInterfaces', () => {
|
||||||
|
it('отбрасывает loopback', () => {
|
||||||
|
const result = suggestedInterfaces(
|
||||||
|
node({
|
||||||
|
system: {
|
||||||
|
info: { networkInterfaces: ['lo', 'ens3', 'wg0'] },
|
||||||
|
stats: {},
|
||||||
|
} as unknown as NodeInfo['system'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(['ens3', 'wg0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без системной информации — пусто', () => {
|
||||||
|
expect(suggestedInterfaces(node())).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/components/admin/remnawave/geoCheckRoute.ts
Normal file
55
src/components/admin/remnawave/geoCheckRoute.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { GeoCheckRequest, NodeInfo } from '@/api/adminRemnawave';
|
||||||
|
|
||||||
|
/** Откуда гнать проверку. */
|
||||||
|
export type GeoCheckRouteMode = 'default' | 'ip' | 'interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Те же правила, что и на бэкенде (`GeocheckRequest`): значение уезжает в
|
||||||
|
* панель и дальше на узел, поэтому форма не должна пропускать мусор. Здесь
|
||||||
|
* проверка нужна не ради безопасности — она ради того, чтобы админ увидел
|
||||||
|
* ошибку сразу, а не через полминуты ожидания.
|
||||||
|
*/
|
||||||
|
const IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
||||||
|
const IPV6_RE = /^[0-9a-fA-F:]+$/;
|
||||||
|
const INTERFACE_RE = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,31}$/;
|
||||||
|
|
||||||
|
export function isValidIpAddress(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if (IPV4_RE.test(trimmed)) return true;
|
||||||
|
// IPv6 без полного разбора: достаточно набора символов и одного «::»-стиля
|
||||||
|
// разделителя. Точный разбор делает панель, здесь важно отсечь опечатки.
|
||||||
|
return trimmed.includes(':') && IPV6_RE.test(trimmed) && !trimmed.includes(':::');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidInterfaceName(value: string): boolean {
|
||||||
|
return INTERFACE_RE.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Готово ли значение к отправке в выбранном режиме. */
|
||||||
|
export function isRouteReady(mode: GeoCheckRouteMode, value: string): boolean {
|
||||||
|
if (mode === 'default') return true;
|
||||||
|
if (mode === 'ip') return isValidIpAddress(value);
|
||||||
|
return isValidInterfaceName(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тело запроса для выбранного режима. */
|
||||||
|
export function buildGeoCheckRequest(mode: GeoCheckRouteMode, value: string): GeoCheckRequest {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (mode === 'ip' && trimmed) return { ip: trimmed };
|
||||||
|
if (mode === 'interface' && trimmed) return { interface: trimmed };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Адреса узла, которые имеет смысл предлагать как исходные. */
|
||||||
|
export function suggestedIps(node: NodeInfo): string[] {
|
||||||
|
const fromPanel = (node.ips ?? []).filter((entry) => entry.status !== 'BLOCKED').map((e) => e.ip);
|
||||||
|
// Адрес самой ноды панель в `ips` не всегда повторяет, а он самый ожидаемый.
|
||||||
|
const all = [node.address, ...fromPanel].filter(Boolean);
|
||||||
|
return Array.from(new Set(all));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сетевые интерфейсы узла; служебный loopback предлагать бессмысленно. */
|
||||||
|
export function suggestedInterfaces(node: NodeInfo): string[] {
|
||||||
|
return (node.system?.info?.networkInterfaces ?? []).filter((name) => name !== 'lo');
|
||||||
|
}
|
||||||
121
src/components/admin/remnawave/useGeoCheckJob.ts
Normal file
121
src/components/admin/remnawave/useGeoCheckJob.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminRemnawaveApi } from '@/api/adminRemnawave';
|
||||||
|
import type { GeoCheckRequest, GeoCheckResult } from '@/api/adminRemnawave';
|
||||||
|
import { getApiErrorMessage } from '@/utils/api-error';
|
||||||
|
|
||||||
|
/** Как часто спрашивать панель о готовности отчёта. */
|
||||||
|
const POLL_INTERVAL_MS = 2500;
|
||||||
|
/** Нода отвечает до минуты; после этого порога считаем, что не дождались. */
|
||||||
|
const POLL_TIMEOUT_MS = 180_000;
|
||||||
|
|
||||||
|
export type GeoCheckPhase = 'idle' | 'running' | 'done' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Почему не получилось:
|
||||||
|
* - `request` — панель не приняла запуск (старая версия, нода офлайн, нет прав);
|
||||||
|
* - `timeout` — задача принята, но результата не дождались;
|
||||||
|
* - `failed` — проверка завершилась неудачей, подробности в `message`.
|
||||||
|
*/
|
||||||
|
export interface GeoCheckError {
|
||||||
|
kind: 'request' | 'timeout' | 'failed';
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoCheckJob {
|
||||||
|
phase: GeoCheckPhase;
|
||||||
|
result: GeoCheckResult | null;
|
||||||
|
error: GeoCheckError | null;
|
||||||
|
/** Запускает проверку; повторный вызов забывает прошлую задачу. */
|
||||||
|
start: (body: GeoCheckRequest) => void;
|
||||||
|
/** Повторяет проверку с теми же параметрами маршрута. */
|
||||||
|
retry: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Жизненный цикл одной проверки GeoCheck.
|
||||||
|
*
|
||||||
|
* Проверка асинхронная: POST отдаёт `job_id`, дальше опрашиваем статус, пока
|
||||||
|
* задача не завершится или не выйдет время. Таймаут обязателен — иначе
|
||||||
|
* оставленная открытой модалка будет опрашивать панель бесконечно.
|
||||||
|
*/
|
||||||
|
export function useGeoCheckJob(nodeUuid: string): GeoCheckJob {
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
|
const [requestError, setRequestError] = useState<string | null>(null);
|
||||||
|
const lastRequest = useRef<GeoCheckRequest>({});
|
||||||
|
const startedAt = useRef(0);
|
||||||
|
|
||||||
|
const startMutation = useMutation({
|
||||||
|
mutationFn: (body: GeoCheckRequest) => adminRemnawaveApi.startNodeGeoCheck(nodeUuid, body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
startedAt.current = Date.now();
|
||||||
|
setJobId(data.job_id);
|
||||||
|
},
|
||||||
|
onError: (error) => setRequestError(getApiErrorMessage(error, '')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobQuery = useQuery({
|
||||||
|
queryKey: ['admin-remnawave-geocheck', jobId],
|
||||||
|
queryFn: () => adminRemnawaveApi.getGeoCheckJob(jobId as string),
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
gcTime: 0,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
if (Date.now() - startedAt.current > POLL_TIMEOUT_MS) return false;
|
||||||
|
const data = query.state.data;
|
||||||
|
if (!data) return POLL_INTERVAL_MS;
|
||||||
|
return data.is_completed || data.is_failed ? false : POLL_INTERVAL_MS;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = useCallback(
|
||||||
|
(body: GeoCheckRequest) => {
|
||||||
|
lastRequest.current = body;
|
||||||
|
setRequestError(null);
|
||||||
|
setJobId(null);
|
||||||
|
startMutation.mutate(body);
|
||||||
|
},
|
||||||
|
[startMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const retry = useCallback(() => start(lastRequest.current), [start]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setJobId(null);
|
||||||
|
setRequestError(null);
|
||||||
|
startMutation.reset();
|
||||||
|
}, [startMutation]);
|
||||||
|
|
||||||
|
const job = jobQuery.data;
|
||||||
|
const settled = Boolean(job?.is_completed || job?.is_failed);
|
||||||
|
const timedOut = Boolean(jobId) && !settled && Date.now() - startedAt.current > POLL_TIMEOUT_MS;
|
||||||
|
// `is_failed` от панели и `success: false` внутри результата — разные вещи:
|
||||||
|
// первое значит «задача упала», второе — «нода ответила, но проверка не
|
||||||
|
// удалась». Админу в обоих случаях нужен `result.message`.
|
||||||
|
const failed = Boolean(job?.is_failed || (settled && job?.result && !job.result.success));
|
||||||
|
|
||||||
|
let phase: GeoCheckPhase = 'idle';
|
||||||
|
let error: GeoCheckError | null = null;
|
||||||
|
|
||||||
|
if (requestError !== null) {
|
||||||
|
phase = 'error';
|
||||||
|
error = { kind: 'request', message: requestError || null };
|
||||||
|
} else if (timedOut) {
|
||||||
|
phase = 'error';
|
||||||
|
error = { kind: 'timeout', message: null };
|
||||||
|
} else if (startMutation.isPending || (jobId && !settled)) {
|
||||||
|
phase = 'running';
|
||||||
|
} else if (settled) {
|
||||||
|
phase = failed ? 'error' : 'done';
|
||||||
|
if (failed) error = { kind: 'failed', message: job?.result?.message ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
result: job?.result ?? null,
|
||||||
|
error,
|
||||||
|
start,
|
||||||
|
retry,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
PiArrowsInSimple,
|
||||||
|
PiArrowsOutSimple,
|
||||||
|
PiCode,
|
||||||
|
PiMapTrifold,
|
||||||
|
PiNetwork,
|
||||||
PiSlidersHorizontal,
|
PiSlidersHorizontal,
|
||||||
PiWrench,
|
PiWrench,
|
||||||
PiBookOpen,
|
PiBookOpen,
|
||||||
@@ -264,6 +269,27 @@ export const ExternalLinkIcon = ({ className }: IconProps) => (
|
|||||||
<PiArrowSquareOut className={cn('h-5 w-5', className)} />
|
<PiArrowSquareOut className={cn('h-5 w-5', className)} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CodeIcon = ({ className }: IconProps) => (
|
||||||
|
<PiCode className={cn('h-5 w-5', className)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CollapseIcon = ({ className }: IconProps) => (
|
||||||
|
<PiArrowsInSimple className={cn('h-5 w-5', className)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ExpandIcon = ({ className }: IconProps) => (
|
||||||
|
<PiArrowsOutSimple className={cn('h-5 w-5', className)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
/** GeoCheck — проверка геоданных ноды (Remnawave 3.3.0). */
|
||||||
|
export const GeoCheckIcon = ({ className }: IconProps) => (
|
||||||
|
<PiMapTrifold className={cn('h-5 w-5', className)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const NetworkIcon = ({ className }: IconProps) => (
|
||||||
|
<PiNetwork className={cn('h-5 w-5', className)} />
|
||||||
|
);
|
||||||
|
|
||||||
export const EyeIcon = ({ className }: IconProps) => <PiEye className={cn('h-5 w-5', className)} />;
|
export const EyeIcon = ({ className }: IconProps) => <PiEye className={cn('h-5 w-5', className)} />;
|
||||||
|
|
||||||
export const FileTextIcon = ({ className }: IconProps) => (
|
export const FileTextIcon = ({ className }: IconProps) => (
|
||||||
|
|||||||
@@ -1841,6 +1841,47 @@
|
|||||||
},
|
},
|
||||||
"usersOnlineCount": "{{count}} online"
|
"usersOnlineCount": "{{count}} online"
|
||||||
},
|
},
|
||||||
|
"geoCheck": {
|
||||||
|
"title": "GeoCheck",
|
||||||
|
"start": "Run check",
|
||||||
|
"running": "Running the geo check",
|
||||||
|
"runningHint": "The node is testing its connection — this usually takes up to a minute.",
|
||||||
|
"changeRoute": "Change route",
|
||||||
|
"rerun": "Run again",
|
||||||
|
"reportAlt": "GeoCheck report for {{node}}",
|
||||||
|
"invalidIp": "Enter a valid IPv4 or IPv6 address",
|
||||||
|
"invalidInterface": "Enter a valid interface name",
|
||||||
|
"fullscreen": "Fullscreen",
|
||||||
|
"exitFullscreen": "Exit fullscreen",
|
||||||
|
"copyJson": "Copy JSON report",
|
||||||
|
"download": "Download SVG",
|
||||||
|
"zoomIn": "Zoom in",
|
||||||
|
"zoomOut": "Zoom out",
|
||||||
|
"zoomReset": "Reset zoom",
|
||||||
|
"mode": {
|
||||||
|
"legend": "Check route",
|
||||||
|
"default": "Default",
|
||||||
|
"ip": "IP address",
|
||||||
|
"interface": "Interface"
|
||||||
|
},
|
||||||
|
"hint": {
|
||||||
|
"default": "The node will use its default outbound route.",
|
||||||
|
"ip": "Pick one of the node addresses or type any address.",
|
||||||
|
"interface": "Pick one of the node interfaces or type any name."
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"default": "Automatic"
|
||||||
|
},
|
||||||
|
"tab": {
|
||||||
|
"report": "Report",
|
||||||
|
"json": "JSON"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Check failed",
|
||||||
|
"timeout": "The node did not answer in time. Try running the check again.",
|
||||||
|
"generic": "The check could not be completed."
|
||||||
|
}
|
||||||
|
},
|
||||||
"squads": {
|
"squads": {
|
||||||
"synced": "Synced",
|
"synced": "Synced",
|
||||||
"notSynced": "Not synced",
|
"notSynced": "Not synced",
|
||||||
|
|||||||
@@ -3464,6 +3464,47 @@
|
|||||||
},
|
},
|
||||||
"usersOnlineCount": "{{count}} آنلاین"
|
"usersOnlineCount": "{{count}} آنلاین"
|
||||||
},
|
},
|
||||||
|
"geoCheck": {
|
||||||
|
"title": "GeoCheck",
|
||||||
|
"start": "اجرای بررسی",
|
||||||
|
"running": "در حال بررسی موقعیت جغرافیایی",
|
||||||
|
"runningHint": "نود در حال آزمایش اتصال است — معمولاً تا یک دقیقه طول میکشد.",
|
||||||
|
"changeRoute": "تغییر مسیر",
|
||||||
|
"rerun": "اجرای دوباره",
|
||||||
|
"reportAlt": "گزارش GeoCheck برای {{node}}",
|
||||||
|
"invalidIp": "یک نشانی معتبر IPv4 یا IPv6 وارد کنید",
|
||||||
|
"invalidInterface": "یک نام رابط شبکه معتبر وارد کنید",
|
||||||
|
"fullscreen": "تمامصفحه",
|
||||||
|
"exitFullscreen": "خروج از تمامصفحه",
|
||||||
|
"copyJson": "کپی گزارش JSON",
|
||||||
|
"download": "دانلود SVG",
|
||||||
|
"zoomIn": "بزرگنمایی",
|
||||||
|
"zoomOut": "کوچکنمایی",
|
||||||
|
"zoomReset": "بازنشانی بزرگنمایی",
|
||||||
|
"mode": {
|
||||||
|
"legend": "مسیر بررسی",
|
||||||
|
"default": "پیشفرض",
|
||||||
|
"ip": "نشانی IP",
|
||||||
|
"interface": "رابط شبکه"
|
||||||
|
},
|
||||||
|
"hint": {
|
||||||
|
"default": "نود از مسیر خروجی پیشفرض خود استفاده میکند.",
|
||||||
|
"ip": "یکی از نشانیهای نود را انتخاب کنید یا هر نشانی دلخواه را وارد کنید.",
|
||||||
|
"interface": "یکی از رابطهای نود را انتخاب کنید یا هر نامی را وارد کنید."
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"default": "خودکار"
|
||||||
|
},
|
||||||
|
"tab": {
|
||||||
|
"report": "گزارش",
|
||||||
|
"json": "JSON"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "بررسی ناموفق بود",
|
||||||
|
"timeout": "نود بهموقع پاسخ نداد. دوباره بررسی را اجرا کنید.",
|
||||||
|
"generic": "انجام بررسی ممکن نشد."
|
||||||
|
}
|
||||||
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"bandwidth": "ترافیک ورودیها",
|
"bandwidth": "ترافیک ورودیها",
|
||||||
"connections": "اتصالات",
|
"connections": "اتصالات",
|
||||||
|
|||||||
@@ -1854,6 +1854,47 @@
|
|||||||
},
|
},
|
||||||
"usersOnlineCount": "{{count}} онлайн"
|
"usersOnlineCount": "{{count}} онлайн"
|
||||||
},
|
},
|
||||||
|
"geoCheck": {
|
||||||
|
"title": "GeoCheck",
|
||||||
|
"start": "Запустить проверку",
|
||||||
|
"running": "Выполняется проверка геоданных",
|
||||||
|
"runningHint": "Нода проверяет соединение — обычно занимает до минуты.",
|
||||||
|
"changeRoute": "Изменить маршрут",
|
||||||
|
"rerun": "Проверить снова",
|
||||||
|
"reportAlt": "Отчёт GeoCheck для {{node}}",
|
||||||
|
"invalidIp": "Введите корректный адрес IPv4 или IPv6",
|
||||||
|
"invalidInterface": "Введите корректное имя интерфейса",
|
||||||
|
"fullscreen": "Во весь экран",
|
||||||
|
"exitFullscreen": "Выйти из полноэкранного режима",
|
||||||
|
"copyJson": "Скопировать отчёт в JSON",
|
||||||
|
"download": "Скачать SVG",
|
||||||
|
"zoomIn": "Увеличить",
|
||||||
|
"zoomOut": "Уменьшить",
|
||||||
|
"zoomReset": "Сбросить масштаб",
|
||||||
|
"mode": {
|
||||||
|
"legend": "Маршрут проверки",
|
||||||
|
"default": "По умолчанию",
|
||||||
|
"ip": "IP-адрес",
|
||||||
|
"interface": "Интерфейс"
|
||||||
|
},
|
||||||
|
"hint": {
|
||||||
|
"default": "Узел будет использовать исходящий маршрут по умолчанию.",
|
||||||
|
"ip": "Выберите один из IP-адресов узла или введите любой адрес.",
|
||||||
|
"interface": "Выберите один из интерфейсов узла или введите любое имя."
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"default": "Автоматически"
|
||||||
|
},
|
||||||
|
"tab": {
|
||||||
|
"report": "Отчёт",
|
||||||
|
"json": "JSON"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Проверка не удалась",
|
||||||
|
"timeout": "Нода не ответила вовремя. Попробуйте запустить проверку ещё раз.",
|
||||||
|
"generic": "Не удалось выполнить проверку."
|
||||||
|
}
|
||||||
|
},
|
||||||
"squads": {
|
"squads": {
|
||||||
"synced": "Синхронизирован",
|
"synced": "Синхронизирован",
|
||||||
"notSynced": "Не синхронизирован",
|
"notSynced": "Не синхронизирован",
|
||||||
|
|||||||
@@ -3472,6 +3472,47 @@
|
|||||||
},
|
},
|
||||||
"usersOnlineCount": "{{count}} 在线"
|
"usersOnlineCount": "{{count}} 在线"
|
||||||
},
|
},
|
||||||
|
"geoCheck": {
|
||||||
|
"title": "GeoCheck",
|
||||||
|
"start": "开始检测",
|
||||||
|
"running": "正在检测地理位置",
|
||||||
|
"runningHint": "节点正在测试连接,通常需要一分钟以内。",
|
||||||
|
"changeRoute": "更改路由",
|
||||||
|
"rerun": "重新检测",
|
||||||
|
"reportAlt": "{{node}} 的 GeoCheck 报告",
|
||||||
|
"invalidIp": "请输入有效的 IPv4 或 IPv6 地址",
|
||||||
|
"invalidInterface": "请输入有效的网卡名称",
|
||||||
|
"fullscreen": "全屏",
|
||||||
|
"exitFullscreen": "退出全屏",
|
||||||
|
"copyJson": "复制 JSON 报告",
|
||||||
|
"download": "下载 SVG",
|
||||||
|
"zoomIn": "放大",
|
||||||
|
"zoomOut": "缩小",
|
||||||
|
"zoomReset": "重置缩放",
|
||||||
|
"mode": {
|
||||||
|
"legend": "检测路由",
|
||||||
|
"default": "默认",
|
||||||
|
"ip": "IP 地址",
|
||||||
|
"interface": "网络接口"
|
||||||
|
},
|
||||||
|
"hint": {
|
||||||
|
"default": "节点将使用默认出站路由。",
|
||||||
|
"ip": "选择节点的某个 IP 地址,或输入任意地址。",
|
||||||
|
"interface": "选择节点的某个网络接口,或输入任意名称。"
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"default": "自动"
|
||||||
|
},
|
||||||
|
"tab": {
|
||||||
|
"report": "报告",
|
||||||
|
"json": "JSON"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "检测失败",
|
||||||
|
"timeout": "节点未及时响应。请重新运行检测。",
|
||||||
|
"generic": "无法完成检测。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"bandwidth": "各 inbound 流量",
|
"bandwidth": "各 inbound 流量",
|
||||||
"connections": "连接数",
|
"connections": "连接数",
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
adminRemnawaveApi,
|
adminRemnawaveApi,
|
||||||
NodeInfo,
|
type NodeInfo,
|
||||||
NodeRealtimeStats,
|
type NodeRealtimeStats,
|
||||||
SquadWithLocalInfo,
|
type SquadWithLocalInfo,
|
||||||
SystemStatsResponse,
|
type SystemStatsResponse,
|
||||||
AutoSyncStatus,
|
type AutoSyncStatus,
|
||||||
RecapResponse,
|
type RecapResponse,
|
||||||
DevicesStatsResponse,
|
type DevicesStatsResponse,
|
||||||
TopConsumersResponse,
|
type TopConsumersResponse,
|
||||||
HealthResponse,
|
type HealthResponse,
|
||||||
SubscriptionRequestStatsResponse,
|
type SubscriptionRequestStatsResponse,
|
||||||
} from '../api/adminRemnawave';
|
} from '../api/adminRemnawave';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { formatUptime } from '../utils/format';
|
import { formatUptime } from '../utils/format';
|
||||||
@@ -54,14 +54,17 @@ import {
|
|||||||
SubscriptionIcon,
|
SubscriptionIcon,
|
||||||
BackIcon,
|
BackIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
GeoCheckIcon,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
|
import { GeoCheckModal } from '../components/admin/remnawave/GeoCheckModal';
|
||||||
|
import { supportsGeoCheck } from '../utils/nodeVersion';
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = (bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
||||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / k ** i).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Алгоритмический ISO 3166-1 alpha-2 → regional indicator. Глобус-fallback
|
// Алгоритмический ISO 3166-1 alpha-2 → regional indicator. Глобус-fallback
|
||||||
@@ -149,6 +152,11 @@ interface NodeCardProps {
|
|||||||
function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCardProps) {
|
function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [geoCheckOpen, setGeoCheckOpen] = useState(false);
|
||||||
|
|
||||||
|
// GeoCheck умеет только узел 3.3.0+; на старом узле кнопку не показываем,
|
||||||
|
// чтобы админ не упирался в ошибку панели.
|
||||||
|
const canGeoCheck = supportsGeoCheck(node.versions);
|
||||||
|
|
||||||
const isUp = node.is_connected && node.is_node_online && !node.is_disabled;
|
const isUp = node.is_connected && node.is_node_online && !node.is_disabled;
|
||||||
const dotColor = node.is_disabled ? 'bg-dark-500' : isUp ? 'bg-success-400' : 'bg-error-400';
|
const dotColor = node.is_disabled ? 'bg-dark-500' : isUp ? 'bg-success-400' : 'bg-error-400';
|
||||||
@@ -233,6 +241,20 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
{canGeoCheck && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setGeoCheckOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={node.is_disabled || !node.is_connected}
|
||||||
|
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
title={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
||||||
|
aria-label={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
||||||
|
>
|
||||||
|
<GeoCheckIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -431,6 +453,8 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{geoCheckOpen && <GeoCheckModal node={node} onClose={() => setGeoCheckOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/utils/nodeVersion.test.ts
Normal file
52
src/utils/nodeVersion.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { GEOCHECK_MIN_NODE_VERSION, isVersionAtLeast, supportsGeoCheck } from './nodeVersion';
|
||||||
|
|
||||||
|
describe('isVersionAtLeast', () => {
|
||||||
|
it.each([
|
||||||
|
['3.3.0', true],
|
||||||
|
['3.3.1', true],
|
||||||
|
['3.4.0', true],
|
||||||
|
['4.0.0', true],
|
||||||
|
['v3.3.0', true],
|
||||||
|
['3.3', true],
|
||||||
|
['3.2.9', false],
|
||||||
|
['3.2.99', false],
|
||||||
|
['2.9.9', false],
|
||||||
|
['3', false],
|
||||||
|
])('%s vs 3.3.0 -> %s', (value, expected) => {
|
||||||
|
expect(isVersionAtLeast(value, '3.3.0')).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сравнивает числа, а не строки: 3.10.0 новее 3.9.0', () => {
|
||||||
|
expect(isVersionAtLeast('3.10.0', '3.9.0')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пререлиз целевой версии считается достаточным — фича там уже есть', () => {
|
||||||
|
expect(isVersionAtLeast('3.3.0-rc.1', '3.3.0')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'unknown',
|
||||||
|
'dev',
|
||||||
|
])('нераспознанное значение %s -> false', (value) => {
|
||||||
|
expect(isVersionAtLeast(value, '3.3.0')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('supportsGeoCheck', () => {
|
||||||
|
it('смотрит именно на версию узла, а не xray', () => {
|
||||||
|
expect(supportsGeoCheck({ node: '3.3.0' })).toBe(true);
|
||||||
|
expect(supportsGeoCheck({ node: '3.2.0' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([undefined, null, {}])('без версии узла — false (%s)', (versions) => {
|
||||||
|
expect(supportsGeoCheck(versions as { node?: string } | null | undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('порог совпадает с задокументированным минимумом', () => {
|
||||||
|
expect(supportsGeoCheck({ node: GEOCHECK_MIN_NODE_VERSION })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/utils/nodeVersion.ts
Normal file
43
src/utils/nodeVersion.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Сравнение версий узла Remnawave.
|
||||||
|
*
|
||||||
|
* Панель отдаёт `versions: { xray, node }` строками вида `3.3.0`; иногда с
|
||||||
|
* префиксом `v` или суффиксом пререлиза (`3.3.0-rc.1`). Нам нужен только
|
||||||
|
* порог «фича появилась начиная с версии X», поэтому сравниваем три числа
|
||||||
|
* и намеренно НЕ разбираем пререлизы по правилам semver: `3.3.0-rc.1`
|
||||||
|
* считается достаточным для 3.3.0 — фича там уже есть.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Версия узла, при которой в панели появился GeoCheck. */
|
||||||
|
export const GEOCHECK_MIN_NODE_VERSION = '3.3.0';
|
||||||
|
|
||||||
|
function parseVersion(value: string): [number, number, number] | null {
|
||||||
|
const match = /^\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(value);
|
||||||
|
if (!match) return null;
|
||||||
|
return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `true`, если `value` не старше `minimum`. Нераспознанная версия — `false`. */
|
||||||
|
export function isVersionAtLeast(value: string | null | undefined, minimum: string): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
|
||||||
|
const parsed = parseVersion(value);
|
||||||
|
const floor = parseVersion(minimum);
|
||||||
|
if (!parsed || !floor) return false;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
if (parsed[i] !== floor[i]) return parsed[i] > floor[i];
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Умеет ли узел в GeoCheck.
|
||||||
|
*
|
||||||
|
* Гейт по версии УЗЛА, а не панели: панель без 3.3.0 ответит на запрос 404, и
|
||||||
|
* бэкенд превратит это в понятную ошибку — а вот кнопку на заведомо старом
|
||||||
|
* узле показывать незачем.
|
||||||
|
*/
|
||||||
|
export function supportsGeoCheck(versions: { node?: string } | null | undefined): boolean {
|
||||||
|
return isVersionAtLeast(versions?.node, GEOCHECK_MIN_NODE_VERSION);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user