mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
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:
120
src/components/admin/remnawave/GeoCheckImageViewer.tsx
Normal file
120
src/components/admin/remnawave/GeoCheckImageViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
src/components/admin/remnawave/GeoCheckModal.tsx
Normal file
196
src/components/admin/remnawave/GeoCheckModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
208
src/components/admin/remnawave/GeoCheckReport.tsx
Normal file
208
src/components/admin/remnawave/GeoCheckReport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
src/components/admin/remnawave/GeoCheckSetup.tsx
Normal file
152
src/components/admin/remnawave/GeoCheckSetup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/components/admin/remnawave/geoCheckRoute.test.ts
Normal file
134
src/components/admin/remnawave/geoCheckRoute.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
55
src/components/admin/remnawave/geoCheckRoute.ts
Normal file
55
src/components/admin/remnawave/geoCheckRoute.ts
Normal 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');
|
||||
}
|
||||
121
src/components/admin/remnawave/useGeoCheckJob.ts
Normal file
121
src/components/admin/remnawave/useGeoCheckJob.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user