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