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

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