diff --git a/package-lock.json b/package-lock.json index 18d0c41..760381b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "react-icons": "^5.6.0", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", + "react-zoom-pan-pinch": "^4.0.4", "recharts": "^3.7.0", "sigma": "^3.0.2", "simplex-noise": "^4.0.3", @@ -6211,6 +6212,20 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index d53ba78..a8c9ae8 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "react-icons": "^5.6.0", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", + "react-zoom-pan-pinch": "^4.0.4", "recharts": "^3.7.0", "sigma": "^3.0.2", "simplex-noise": "^4.0.3", diff --git a/src/api/adminRemnawave.ts b/src/api/adminRemnawave.ts index 9e86819..4ecf105 100644 --- a/src/api/adminRemnawave.ts +++ b/src/api/adminRemnawave.ts @@ -112,6 +112,23 @@ export interface SystemStatsResponse { } // Nodes +export type NodeIpStatus = + | 'INBOUND' + | 'OUTBOUND' + | 'MANAGEMENT' + | 'TRANSIT' + | 'MONITORING' + | 'RESERVE' + | 'BLOCKED' + | 'FLAGGED' + | 'DEPRECATED' + | 'UNKNOWN'; + +export interface NodeIpAddress { + ip: string; + status: NodeIpStatus; +} + export interface NodeInfo { uuid: string; name: string; @@ -165,6 +182,8 @@ export interface NodeInfo { }; } | null; active_plugin_uuid?: string; + /** Адреса узла из панели — варианты исходного адреса для GeoCheck. */ + ips?: NodeIpAddress[]; config_profile?: { active_config_profile_uuid: string | null; active_inbounds: Array<{ @@ -206,6 +225,40 @@ export interface NodeActionResponse { 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 | null; + message?: string | null; +} + +export interface GeoCheckJobResponse { + job_id: string; + is_completed: boolean; + is_failed: boolean; + result?: GeoCheckResult | null; +} + // Realtime Traffic export interface InboundTraffic { tag: string; @@ -403,6 +456,21 @@ export const adminRemnawaveApi = { return response.data; }, + /** Ставит GeoCheck ноды в очередь; результат забирается по job_id. */ + startNodeGeoCheck: async ( + uuid: string, + body: GeoCheckRequest = {}, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/geocheck`, body); + return response.data; + }, + + /** Статус задачи GeoCheck — нода отвечает до минуты. */ + getGeoCheckJob: async (jobId: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/remnawave/geocheck/${jobId}`); + return response.data; + }, + restartAllNodes: async (): Promise => { const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all'); return response.data; diff --git a/src/components/admin/remnawave/GeoCheckImageViewer.tsx b/src/components/admin/remnawave/GeoCheckImageViewer.tsx new file mode 100644 index 0000000..253cffc --- /dev/null +++ b/src/components/admin/remnawave/GeoCheckImageViewer.tsx @@ -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(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 ( +
+ {width > 0 && ( + + {({ resetTransform, zoomIn, zoomOut }) => ( + <> + + {alt} + + +
+ + + + +
+ + )} +
+ )} +
+ ); +} diff --git a/src/components/admin/remnawave/GeoCheckModal.tsx b/src/components/admin/remnawave/GeoCheckModal.tsx new file mode 100644 index 0000000..a416723 --- /dev/null +++ b/src/components/admin/remnawave/GeoCheckModal.tsx @@ -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('default'); + const [value, setValue] = useState(''); + const [fullscreen, setFullscreen] = useState(false); + + const job = useGeoCheckJob(node.uuid); + const isRunning = job.phase === 'running'; + + // Пока идёт проверка, закрывать по Escape нельзя: задача уже поставлена + // в панель, и молча потерять её результат — хуже, чем подождать. + const dialogRef = useFocusTrap(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( +
+
+ +
+
+
+ + + +
+

+ {t('admin.remnawave.geoCheck.title', 'GeoCheck')} +

+

{node.name}

+
+
+ {!isRunning && ( + + )} +
+ + {job.phase === 'idle' && ( + <> + { + setMode(next); + setValue(''); + }} + onValueChange={setValue} + /> +
+ + +
+ + )} + + {isRunning && ( +
+ +

+ {t('admin.remnawave.geoCheck.running', 'Running the geo check')} +

+

+ {t( + 'admin.remnawave.geoCheck.runningHint', + 'The node is testing its connection — this usually takes up to a minute.', + )} +

+
+ )} + + {job.phase === 'error' && ( +
+ + + +

+ {t('admin.remnawave.geoCheck.error.title', 'Check failed')} +

+

{errorText}

+
+ + +
+
+ )} + + {job.phase === 'done' && job.result && ( + setFullscreen((v) => !v)} + onRerun={job.retry} + /> + )} +
+
, + document.body, + ); +} diff --git a/src/components/admin/remnawave/GeoCheckReport.tsx b/src/components/admin/remnawave/GeoCheckReport.tsx new file mode 100644 index 0000000..acd2ef0 --- /dev/null +++ b/src/components/admin/remnawave/GeoCheckReport.tsx @@ -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. + * + * Картинка вставляется как ``, а не сырым SVG в разметку: + * отчёт несёт встроенный моноширинный шрифт и `