// Copyright (c) 2026 Ivan Vasilev // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. import { useState, useEffect, useCallback } from 'react'; import { apiFetch, copyText, type PageProps } from '../../lib/shared'; import { IcoCopy } from '../../components/icons'; import './migrate.css'; // Вкладка «Перенос»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а // старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама // отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в // internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели». type MoveState = '' | 'sending' | 'moved' | 'uncertain' | 'imported'; // Новая панель после приёма: кто уже знает её адрес — тогда старый сервер можно выключать. interface Retire { since: number; home: string | null; // адрес хаба для нод; null — не задан SUB_HOST old: { exists: boolean; online: boolean }; devices: { total: number; fresh: number }; // fresh — получили конфиг после переноса unused_links: number; // мастер-ключи без устройств: в ссылке старый адрес nodes: { id: number; name: string; online: boolean; ok: boolean }[]; } interface MigrateInfo { state: MoveState; error: string | null; target: string | null; // старая: адрес новой панели code: { code: string; expires_in: number } | null; export?: string | null; // null — можно, строка — почему нельзя accept?: string | null; retire?: Retire | null; } const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса'; export default function MigratePage({ token, showMsg }: PageProps) { const [info, setInfo] = useState(null); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const load = useCallback(async () => { try { setInfo(await apiFetch('GET', '/ui/migrate', token)); } catch { /* панель перезапускается — следующий опрос покажет */ } }, [token]); useEffect(() => { if (token) load(); }, [token, load]); // Пока висит код — ждём, когда старая панель пришлёт архив; после приёма панель // перезапускается, и страница перечитывается уже с перенесённой базой. const waiting = !!info?.code || info?.state === 'imported' || !!info?.retire; useEffect(() => { if (!waiting) return; const t = setInterval(load, 3000); return () => clearInterval(t); }, [waiting, load]); useEffect(() => { if (info?.state !== 'imported' || info.error) return; const t = setTimeout(() => window.location.reload(), 8000); return () => clearTimeout(t); }, [info?.state, info?.error]); const call = async (method: string, path: string, body?: object) => { setBusy(true); setError(''); try { setInfo(await apiFetch(method, `/ui/migrate${path}`, token, body)); } catch (e) { setError(errText(e)); } finally { setBusy(false); } }; const send = async () => { if (!window.confirm('Перенести панель? Здесь она остановится, а этот сервер станет нодой новой панели.')) return; setBusy(true); setError(''); try { await apiFetch('POST', '/ui/migrate/export', token, { code: code.trim() }); await load(); } catch (e) { setError(errText(e)); await load(); } finally { setBusy(false); } }; if (!info) return null; if (info.state === 'moved') return (
Панель перенесена

Новая панель: {info.target}. Через несколько секунд этот сервер станет её нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать, а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели.

{info.target && Открыть новую панель}
); if (info.state === 'imported') return (
{info.error ? 'Приём прерван' : 'Панель принята'}

{info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'}

); const r = info.retire; const retireBox = r && (() => { const devicesOk = r.devices.fresh >= r.devices.total; const nodesOk = r.nodes.every(n => n.ok); const ready = devicesOk && nodesOk && !!r.home; const chip = (ok: boolean, text: string, offline = false) => ( {text} ); return (

Отключение старого сервера

{r.old.exists ? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними. : <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.}

{chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)} устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'}
{r.nodes.map(n => (
{chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)} нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'}
))} {r.unused_links > 0 && (
{chip(false, String(r.unused_links), true)} мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же
)}
{!r.home &&

Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.

}
  1. Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.
  2. Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.
  3. Удалите сервер «#0» и выключите старую машину.
{!r.old.exists && ( )}
); })(); const canExport = info.export === null, canAccept = info.accept === null; const exportBox = (

Перенести эту панель на другой сервер

{canExport ? ( <>

Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели. Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними.

  1. На новом сервере установите Forgetting с ролью «только панель».
  2. Там на вкладке «Перенос» нажмите «Получить код».
  3. Вставьте код сюда и нажмите «Перенести».
{info.state === 'uncertain' ? ( <>

{info.error}

) : (
setCode(e.target.value)} />
)} ) :

{info.export}

}
); const acceptBox = (

Принять панель с другого сервера

{canAccept ? ( info.code ? ( <>

Вставьте код на вкладке «Перенос» старой панели. Он одноразовый и действует ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.

{info.code.code}

Ждём архив от старой панели…

) : ( <>

Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней нодой #0. Логин и пароль останутся этой панели.

) ) :

{info.accept}

}
); return ( <> {/* Доступное действие — первым. */} {retireBox} {canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]} {error &&

{error}

} ); }