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:
c0mrade
2026-08-19 16:01:05 +03:00
parent f81af401f7
commit b4e1de6152
18 changed files with 1390 additions and 11 deletions

15
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View 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>
);
}

View 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,
);
}

View 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>
);
}

View 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>
);
}

View 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([]);
});
});

View 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');
}

View 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,
};
}

View File

@@ -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) => (

View File

@@ -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",

View File

@@ -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": "اتصالات",

View File

@@ -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": "Не синхронизирован",

View File

@@ -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": "连接数",

View File

@@ -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>
); );
} }

View 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
View 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);
}