Move the panel to a server without VPN; servers for all master keys at once
The «Перенос» tab moves a panel + VPN server to a panel-only server without users noticing: the new panel issues a one-time awgmove:// code, the old one sends its ui.db, subscription keys and hub certificate encrypted with it, and becomes node #0 of the new panel. Server id 0 is kept, so config servers[] is byte-identical. The old server relays its former ports (awg-relay, PROXY v1 header trusted only from RELAY_FROM), so sen:// links, nodes and the API keep working. The same is available from the CLI: migrate export/import/cancel, rehome. Nodes now learn the panel's address: the hub sends it in ready, the agent stores it in CORE_ENDPOINT, dials it first and confirms with core_ok. The new panel shows when the old server can be turned off: devices that got the new address, nodes that confirmed it, master keys never opened. «Серверы для всех» on the master keys tab adds or removes servers on every live key in one go, with a dry run that warns about API keys that would lose sight of some keys, and sets the servers new keys get.
This commit is contained in:
@@ -102,9 +102,16 @@ export const IcoPulse = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcoMove = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="8" height="16" rx="2"/><rect x="14" y="4" width="8" height="16" rx="2"/><path d="M7 12h10"/><path d="m14 9 3 3-3 3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ICONS: Record<string, () => JSX.Element> = {
|
||||
users: IcoUsers,
|
||||
key: IcoKey,
|
||||
shield: IcoShield,
|
||||
pulse: IcoPulse,
|
||||
move: IcoMove,
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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, useMemo } from 'react';
|
||||
import { apiFetch, genLabel, type NodeInfo } from '../../lib/shared';
|
||||
|
||||
// «Серверы для всех»: один набор правок на все мастер-ключи (POST /ui/masterkeys/servers).
|
||||
// У каждого сервера — «у всех», «ни у кого» или «как есть» (если он стоит только у части ключей).
|
||||
// Перед применением сервер считает, сколько ключей изменится и какие ключи API потеряют доступ к
|
||||
// части из них (ключ виден API-ключу, только пока все его серверы в области).
|
||||
type Pick = 'all' | 'none' | 'keep';
|
||||
interface Preview { changed: number; total: number; hidden: { id: number; label: string; keys: number }[] }
|
||||
|
||||
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||
|
||||
export default function BulkServersModal({ token, nodes, keys, defaults, onClose, onDone }: {
|
||||
token: string;
|
||||
nodes: NodeInfo[];
|
||||
keys: { servers: number[] }[]; // живые мастер-ключи
|
||||
defaults: number[] | null;
|
||||
onClose: () => void;
|
||||
onDone: (changed: number) => void;
|
||||
}) {
|
||||
const total = keys.length;
|
||||
const count = (id: number) => keys.filter(k => k.servers.includes(id)).length;
|
||||
const initial = (id: number): Pick => { const n = count(id); return n === total && total > 0 ? 'all' : n === 0 ? 'none' : 'keep'; };
|
||||
const [picks, setPicks] = useState<Record<number, Pick>>(() => Object.fromEntries(nodes.map(n => [n.id, initial(n.id)])));
|
||||
const [forNew, setForNew] = useState(true);
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const { add, remove } = useMemo(() => ({
|
||||
add: nodes.filter(n => picks[n.id] === 'all' && count(n.id) < total).map(n => n.id),
|
||||
remove: nodes.filter(n => picks[n.id] === 'none' && count(n.id) > 0).map(n => n.id),
|
||||
}), [picks, nodes, keys]);
|
||||
const all = nodes.filter(n => picks[n.id] === 'all').map(n => n.id);
|
||||
const sameDefaults = JSON.stringify([...all].sort()) === JSON.stringify([...(defaults ?? [])].sort());
|
||||
const body = { add, remove, ...(forNew && !sameDefaults ? { defaults: all.length ? all : null } : {}) };
|
||||
const nothing = !add.length && !remove.length && !('defaults' in body);
|
||||
|
||||
useEffect(() => {
|
||||
if (!add.length && !remove.length) { setPreview(null); setError(''); return; }
|
||||
let live = true;
|
||||
apiFetch('POST', '/ui/masterkeys/servers', token, { add, remove, dry_run: true })
|
||||
.then(p => { if (live) { setPreview(p); setError(''); } })
|
||||
.catch(e => { if (live) { setPreview(null); setError(errText(e)); } });
|
||||
return () => { live = false; };
|
||||
}, [token, add, remove]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const apply = async () => {
|
||||
setBusy(true);
|
||||
try { const r = await apiFetch('POST', '/ui/masterkeys/servers', token, body); onDone(r.changed ?? 0); }
|
||||
catch (e) { setError(errText(e)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const option = (id: number, p: Pick, label: string) => (
|
||||
<button type="button" className={`bs-opt${picks[id] === p ? ' bs-opt--on' : ''}`} aria-pressed={picks[id] === p}
|
||||
onClick={() => setPicks(prev => ({ ...prev, [id]: p }))}>{label}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="node-scrim" onClick={onClose}>
|
||||
<div className="node-modal bs-modal" role="dialog" aria-modal="true" aria-label="Серверы для всех ключей" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="node-modal-title">Серверы для всех ключей</h3>
|
||||
<p className="node-modal-hint">
|
||||
Применится ко всем мастер-ключам сразу ({total}). Пользователям ничего делать не нужно: приложение получит
|
||||
новый набор серверов при следующем обновлении подписки, ссылки и ключи остаются прежними.
|
||||
</p>
|
||||
<div className="bs-list">
|
||||
{nodes.map(n => {
|
||||
const c = count(n.id), partial = c > 0 && c < total;
|
||||
return (
|
||||
<div className="bs-row" key={n.id}>
|
||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<span className="bs-name">
|
||||
<span className="server-pick-name">{n.health?.server || n.name}</span>
|
||||
<span className="bs-sub">
|
||||
{n.health?.ip && <code className="server-pick-ip">{n.health.ip}</code>}
|
||||
{n.health?.gen && <span>{genLabel(n.health.gen)}</span>}
|
||||
<span>сейчас у {c} из {total}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="bs-opts" role="group" aria-label={`Сервер ${n.name}`}>
|
||||
{option(n.id, 'all', 'У всех')}
|
||||
{partial && option(n.id, 'keep', 'Как есть')}
|
||||
{option(n.id, 'none', 'Ни у кого')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="bs-check">
|
||||
<input type="checkbox" checked={forNew} onChange={e => setForNew(e.target.checked)} />
|
||||
<span>Новым ключам — серверы «У всех»{all.length ? '' : ' (не выбрано ни одного — как раньше: все подключённые)'}</span>
|
||||
</label>
|
||||
{preview && (
|
||||
<p className="node-modal-hint">Изменится ключей: {preview.changed} из {preview.total}.</p>
|
||||
)}
|
||||
{preview?.hidden.map(h => (
|
||||
<p className="mk-notice" key={h.id}>
|
||||
Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области.
|
||||
Расширьте его область на вкладке «API-ключи», если он ими управляет.
|
||||
</p>
|
||||
))}
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<div className="node-modal-actions">
|
||||
<button className="btn btn--outline" onClick={onClose}>Отмена</button>
|
||||
<button className="btn btn--primary" disabled={busy || nothing || !!error} onClick={apply}>
|
||||
{busy ? 'Применяем…' : 'Применить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
// 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 { Fragment, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
|
||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
||||
import PriorityChip from '../../components/PriorityChip';
|
||||
import TorrentChip from '../../components/TorrentChip';
|
||||
import BulkServersModal from './BulkServersModal';
|
||||
import './masterkeys.css';
|
||||
|
||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||
@@ -66,6 +67,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
|
||||
const creatingRef = useRef(false);
|
||||
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
|
||||
const [defaults, setDefaults] = useState<number[] | null>(null); // серверы новых ключей из «Серверы для всех»
|
||||
const [bulk, setBulk] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current;
|
||||
@@ -73,7 +76,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
const d = await apiFetch('GET', '/ui/masterkeys', token);
|
||||
const list: MasterKey[] = d.keys ?? [];
|
||||
if (seq !== loadSeq.current) return;
|
||||
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
|
||||
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setDefaults(d.defaults ?? null);
|
||||
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
|
||||
const live = list.filter(k => !k.deleting);
|
||||
@@ -106,15 +109,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
if (!label.trim() || creatingRef.current) return;
|
||||
creatingRef.current = true; setCreating(true);
|
||||
try { await act(async () => {
|
||||
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
|
||||
// Новый ключ — с серверами из «Серверы для всех» (их подставит панель), а не задано — со всеми,
|
||||
// которые уже подключались; набор потом правится в настройках ключа.
|
||||
const ready = nodes.filter(n => !n.pending).map(n => n.id);
|
||||
await apiFetch('POST', '/ui/masterkeys', token, {
|
||||
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
|
||||
label: label.trim(), device_limit: Number(limit), ...(!defaults && ready.length ? { servers: ready } : {}),
|
||||
});
|
||||
setLabel('');
|
||||
}, 'Мастер-ключ создан'); }
|
||||
finally { creatingRef.current = false; setCreating(false); }
|
||||
}, [label, limit, nodes, token, act]);
|
||||
}, [label, limit, nodes, defaults, token, act]);
|
||||
const live = useMemo(() => keys.filter(k => !k.deleting), [keys]);
|
||||
|
||||
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
|
||||
|
||||
@@ -403,6 +408,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
|
||||
<button className="btn btn--tonal" onClick={() => setBulk(true)} disabled={!live.length || !nodes.length}
|
||||
title="Добавить или убрать серверы у всех мастер-ключей сразу"><IcoSettings /> Серверы для всех</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -518,6 +525,11 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{bulk && (
|
||||
<BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)}
|
||||
onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} />
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<div className="qr-backdrop" onClick={() => setModal(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -279,3 +279,33 @@ LICENSE file in the root directory of this source tree.
|
||||
|
||||
.mk-free { padding: 12px; font-size: 13px; }
|
||||
}
|
||||
|
||||
/* «Серверы для всех» (BulkServersModal) */
|
||||
.bs-modal { max-width: 620px; }
|
||||
.bs-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.bs-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--surface-container); border-radius: 12px; padding: 8px 8px 8px 14px;
|
||||
font-size: 13px; color: var(--on-surface);
|
||||
}
|
||||
.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--neutral-text); }
|
||||
.bs-sub .server-pick-ip { margin-left: 0; }
|
||||
.bs-opts { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.bs-opt {
|
||||
min-height: 36px; padding: 0 12px;
|
||||
font: inherit; font-size: 13px; color: var(--on-surface);
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline); border-radius: var(--r-pill);
|
||||
cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.bs-opt:hover { background: var(--state-primary); }
|
||||
.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
|
||||
.bs-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--on-surface); cursor: pointer; }
|
||||
.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
|
||||
@media (max-width: 640px) {
|
||||
.bs-row { flex-wrap: wrap; }
|
||||
.bs-opts { width: 100%; }
|
||||
.bs-opt { flex: 1; }
|
||||
}
|
||||
.bs-modal .btn:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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<MigrateInfo | null>(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 (
|
||||
<section className="mv-box">
|
||||
<span className="chip chip--online mv-self"><span className="chip-dot" />Панель перенесена</span>
|
||||
<p className="mv-text">
|
||||
Новая панель: <a href={info.target ?? '#'}>{info.target}</a>. Через несколько секунд этот сервер станет её
|
||||
нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать,
|
||||
а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели.
|
||||
</p>
|
||||
{info.target && <a className="btn btn--primary mv-self" href={info.target}>Открыть новую панель</a>}
|
||||
</section>
|
||||
);
|
||||
|
||||
if (info.state === 'imported') return (
|
||||
<section className="mv-box">
|
||||
<span className={`chip chip--${info.error ? 'error' : 'online'} mv-self`}><span className="chip-dot" />
|
||||
{info.error ? 'Приём прерван' : 'Панель принята'}</span>
|
||||
<p className="mv-text">
|
||||
{info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
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) => (
|
||||
<span className={`chip chip--${ok ? 'online' : offline ? 'offline' : 'warm'}`}><span className="chip-dot" />{text}</span>
|
||||
);
|
||||
return (
|
||||
<section className="mv-box" key="retire">
|
||||
<h4 className="mv-title">Отключение старого сервера</h4>
|
||||
<p className="mv-text">
|
||||
{r.old.exists
|
||||
? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его
|
||||
можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними.</>
|
||||
: <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.</>}
|
||||
</p>
|
||||
<div className="mv-checks">
|
||||
<div className="mv-check">
|
||||
{chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)}
|
||||
<span>устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'}</span>
|
||||
</div>
|
||||
{r.nodes.map(n => (
|
||||
<div className="mv-check" key={n.id}>
|
||||
{chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)}
|
||||
<span>нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'}</span>
|
||||
</div>
|
||||
))}
|
||||
{r.unused_links > 0 && (
|
||||
<div className="mv-check">
|
||||
{chip(false, String(r.unused_links), true)}
|
||||
<span>мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на
|
||||
вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!r.home && <p className="mv-notice">Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.</p>}
|
||||
<ol className="mv-steps">
|
||||
<li>Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.</li>
|
||||
<li>Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.</li>
|
||||
<li>Удалите сервер «#0» и выключите старую машину.</li>
|
||||
</ol>
|
||||
{!r.old.exists && (
|
||||
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('DELETE', '/retire')}>Готово, скрыть</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})();
|
||||
|
||||
const canExport = info.export === null, canAccept = info.accept === null;
|
||||
const exportBox = (
|
||||
<section className="mv-box" key="export">
|
||||
<h4 className="mv-title">Перенести эту панель на другой сервер</h4>
|
||||
{canExport ? (
|
||||
<>
|
||||
<p className="mv-text">
|
||||
Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели.
|
||||
Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними.
|
||||
</p>
|
||||
<ol className="mv-steps">
|
||||
<li>На новом сервере установите Forgetting с ролью «только панель».</li>
|
||||
<li>Там на вкладке «Перенос» нажмите «Получить код».</li>
|
||||
<li>Вставьте код сюда и нажмите «Перенести».</li>
|
||||
</ol>
|
||||
{info.state === 'uncertain' ? (
|
||||
<>
|
||||
<p className="mv-notice">{info.error}</p>
|
||||
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('POST', '/cancel')}>
|
||||
Отменить перенос
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="mv-row">
|
||||
<input className="field" placeholder="awgmove://…" value={code} disabled={busy}
|
||||
onChange={e => setCode(e.target.value)} />
|
||||
<button className="btn btn--primary" disabled={busy || !code.trim().startsWith('awgmove://')} onClick={send}>
|
||||
{busy ? 'Переносим…' : 'Перенести'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : <p className="mv-text mv-muted">{info.export}</p>}
|
||||
</section>
|
||||
);
|
||||
const acceptBox = (
|
||||
<section className="mv-box" key="accept">
|
||||
<h4 className="mv-title">Принять панель с другого сервера</h4>
|
||||
{canAccept ? (
|
||||
info.code ? (
|
||||
<>
|
||||
<p className="mv-text">
|
||||
Вставьте код на вкладке «Перенос» старой панели. Он одноразовый и действует
|
||||
ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.
|
||||
</p>
|
||||
<code className="node-modal-cmd">{info.code.code}</code>
|
||||
<div className="mv-row mv-row--end">
|
||||
<button className="btn btn--tonal" onClick={() => copyText(info.code!.code).then(() => showMsg('Код скопирован'))}>
|
||||
<IcoCopy /> Скопировать
|
||||
</button>
|
||||
<button className="btn btn--outline" disabled={busy} onClick={() => call('DELETE', '/code')}>Отозвать</button>
|
||||
</div>
|
||||
<p className="mv-text mv-muted">Ждём архив от старой панели…</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mv-text">
|
||||
Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней
|
||||
нодой #0. Логин и пароль останутся этой панели.
|
||||
</p>
|
||||
<button className="btn btn--primary mv-self" disabled={busy}
|
||||
onClick={() => call('POST', '/code', { host: window.location.hostname })}>
|
||||
Получить код
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : <p className="mv-text mv-muted">{info.accept}</p>}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Доступное действие — первым. */}
|
||||
{retireBox}
|
||||
{canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]}
|
||||
{error && <p className="mv-notice">{error}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "migrate",
|
||||
"label": "Перенос",
|
||||
"icon": "move",
|
||||
"group": "server",
|
||||
"order": 1.9,
|
||||
"chrome": {
|
||||
"serverBar": false,
|
||||
"restart": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
|
||||
|
||||
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
|
||||
.mv-title { margin: 0; font-size: 14px; font-weight: 500; }
|
||||
.mv-text { margin: 0; font-size: 14px; line-height: 1.5; }
|
||||
.mv-muted { font-size: 13px; color: var(--neutral-text); }
|
||||
.mv-steps { margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.6; }
|
||||
.mv-row { display: flex; gap: 8px; }
|
||||
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; }
|
||||
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
|
||||
.mv-self { align-self: flex-start; }
|
||||
.mv-notice {
|
||||
font-size: 13px; color: var(--error-text); background: var(--error-bg);
|
||||
border-radius: 16px; padding: 10px 16px; margin: 0;
|
||||
}
|
||||
.mv-box .node-modal-cmd { background: var(--surface-bright); }
|
||||
@media (max-width: 640px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
|
||||
.mv-box .btn:disabled { opacity: .5; cursor: default; }
|
||||
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: 14px; line-height: 1.5; }
|
||||
.mv-check .chip { flex-shrink: 0; }
|
||||
Reference in New Issue
Block a user