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