feat(frontend): confirm before stopping Xray and offer Start when it is stopped (#7104)

Stop sat next to Restart on the dashboard and fired on the first click. A
mis-click cut every proxy connection, and when the panel itself is reached
through that proxy the only way back was SSH (#7103).

Stop now opens a confirmation with Cancel focused. When Xray is stopped the
first button reads Start instead of Restart and Stop is disabled; in the
crashed state Stop stays enabled, because setting the manual-stop flag is
what halts the crash-recovery restart loop.

Closes #7103
This commit is contained in:
Nikan Zeyaei
2026-11-13 19:33:01 +03:30
committed by GitHub
parent 21aa662629
commit 166d5633cc
16 changed files with 198 additions and 17 deletions
+39 -17
View File
@@ -1,7 +1,7 @@
import { Fragment } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Tag, Tooltip } from 'antd';
import { Button, Modal, Tag, Tooltip } from 'antd';
import {
ApiOutlined,
ArrowUpOutlined,
@@ -11,6 +11,7 @@ import {
CloudServerOutlined,
ControlOutlined,
FileTextOutlined,
PlayCircleOutlined,
PoweroffOutlined,
ReloadOutlined,
} from '@ant-design/icons';
@@ -26,7 +27,7 @@ interface OverviewActionBarProps {
panelVersion: string;
latestVersion: string;
updateAvailable: boolean;
onStopXray: () => void;
onStopXray: () => void | Promise<void>;
onRestartXray: () => void;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
@@ -45,6 +46,9 @@ interface BarAction {
text: string;
onClick: () => void;
primary?: boolean;
danger?: boolean;
disabled?: boolean;
hint?: string;
}
const XRAY_STATE_KEYS: Record<string, string> = {
@@ -73,16 +77,18 @@ export default function OverviewActionBar({
onOpenVersionSwitch,
}: OverviewActionBarProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
const hasVersion = !!status.xray.version && status.xray.version !== 'Unknown';
const size = isMobile ? ('small' as const) : ('middle' as const);
const isStopped = status.xray.state === 'stop';
const actionGroups: BarAction[][] = [
[
{
key: 'restart',
icon: <ReloadOutlined aria-hidden />,
text: t('pages.index.restartXray'),
icon: isStopped ? <PlayCircleOutlined aria-hidden /> : <ReloadOutlined aria-hidden />,
text: t(isStopped ? 'pages.index.startXray' : 'pages.index.restartXray'),
onClick: onRestartXray,
primary: true,
},
@@ -90,7 +96,19 @@ export default function OverviewActionBar({
key: 'stop',
icon: <PoweroffOutlined aria-hidden />,
text: t('pages.index.stopXray'),
onClick: onStopXray,
danger: true,
disabled: isStopped,
hint: isStopped ? t('pages.index.stopXrayDisabledHint') : undefined,
onClick: () =>
modal.confirm({
title: t('pages.index.stopXrayConfirmTitle'),
content: t('pages.index.stopXrayConfirmContent'),
okText: t('pages.index.stopXrayConfirmOk'),
okType: 'danger',
cancelText: t('cancel'),
focusable: { autoFocusButton: 'cancel' },
onOk: onStopXray,
}),
},
],
[
@@ -165,6 +183,7 @@ export default function OverviewActionBar({
return (
<div className="ov-bar">
{modalContextHolder}
{status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
{statePill}
@@ -204,18 +223,21 @@ export default function OverviewActionBar({
<Fragment key={group[0].key}>
{groupIndex > 0 && <span className="ov-bar-sep" />}
{group.map((action) => (
<Button
key={action.key}
type={action.primary ? undefined : 'text'}
color={action.primary ? 'primary' : undefined}
variant={action.primary ? 'outlined' : undefined}
size={size}
icon={action.icon}
aria-label={action.text}
onClick={action.onClick}
>
{isMobile ? undefined : action.text}
</Button>
<Tooltip key={action.key} title={action.hint}>
<Button
type={action.primary ? undefined : 'text'}
color={action.primary ? 'primary' : undefined}
variant={action.primary ? 'outlined' : undefined}
size={size}
danger={action.danger}
disabled={action.disabled}
icon={action.icon}
aria-label={action.text}
onClick={action.onClick}
>
{isMobile ? undefined : action.text}
</Button>
</Tooltip>
))}
</Fragment>
))}
@@ -0,0 +1,89 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Status } from '@/models/status';
import OverviewActionBar from '@/pages/index/OverviewActionBar';
import { renderWithProviders } from './test-utils';
const noop = () => {};
function renderBar(
state: string,
{
onStopXray = noop,
onRestartXray = noop,
}: { onStopXray?: () => void; onRestartXray?: () => void } = {},
) {
const status = new Status();
status.xray.state = state;
renderWithProviders(
<OverviewActionBar
status={status}
isMobile={false}
accessLogEnable={false}
panelVersion="v3.10.1"
latestVersion="v3.10.1"
updateAvailable={false}
onStopXray={onStopXray}
onRestartXray={onRestartXray}
onOpenLogs={noop}
onOpenXrayLogs={noop}
onOpenAmneziaWGLogs={noop}
onOpenConfig={noop}
onOpenBackup={noop}
onOpenSystemHistory={noop}
onOpenXrayMetrics={noop}
onOpenPanelUpdate={noop}
onOpenVersionSwitch={noop}
/>,
);
}
// Stop used to fire on the first click, so a mis-click killed every proxy connection (#7103).
describe('OverviewActionBar stop confirmation', () => {
it('does not stop Xray until the dialog is confirmed', async () => {
const onStopXray = vi.fn();
renderBar('running', { onStopXray });
fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
expect(await screen.findAllByText('Stop Xray?')).not.toHaveLength(0);
expect(onStopXray).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onStopXray).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
await waitFor(() =>
expect(screen.getAllByRole('button', { name: 'Stop Xray' })).not.toHaveLength(0),
);
fireEvent.click(screen.getAllByRole('button', { name: 'Stop Xray' }).at(-1)!);
await waitFor(() => expect(onStopXray).toHaveBeenCalledOnce());
});
});
// Stop on a stopped core only errors and sets the manual-stop flag. In the crash
// state Stop is the one dashboard control that halts the restart loop.
describe('OverviewActionBar start / disabled stop', () => {
it('offers Start and disables Stop when the core is stopped', () => {
const onRestartXray = vi.fn();
renderBar('stop', { onRestartXray });
expect(screen.queryByRole('button', { name: 'Restart' })).toBeNull();
expect((screen.getByRole('button', { name: 'Stop' }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
expect(onRestartXray).toHaveBeenCalledOnce();
});
it('keeps Restart and leaves Stop usable when the core errored', async () => {
const onStopXray = vi.fn();
renderBar('error', { onStopXray });
expect(screen.getByRole('button', { name: 'Restart' })).toBeTruthy();
const stop = screen.getByRole('button', { name: 'Stop' }) as HTMLButtonElement;
expect(stop.disabled).toBe(false);
fireEvent.click(stop);
fireEvent.click(await screen.findByRole('button', { name: 'Stop Xray' }));
await waitFor(() => expect(onStopXray).toHaveBeenCalledOnce());
});
});
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "إيقاف",
"restartXray": "إعادة تشغيل",
"startXray": "تشغيل",
"stopXrayDisabledHint": "Xray غير قيد التشغيل",
"stopXrayConfirmTitle": "إيقاف Xray؟",
"stopXrayConfirmContent": "سيؤدي إيقاف Xray إلى قطع جميع اتصالات البروكسي فورًا. هل تريد المتابعة؟",
"stopXrayConfirmOk": "إيقاف Xray",
"xraySwitch": "النسخة",
"xrayUpdates": "تحديثات Xray",
"xraySwitchClickDesk": "اختار بحذر، النسخ القديمة ممكن ما تتوافقش مع الإعدادات الحالية.",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "Stop",
"restartXray": "Restart",
"startXray": "Start",
"stopXrayDisabledHint": "Xray is not running",
"stopXrayConfirmTitle": "Stop Xray?",
"stopXrayConfirmContent": "Stopping Xray will immediately interrupt all proxy connections. Are you sure you want to continue?",
"stopXrayConfirmOk": "Stop Xray",
"xraySwitch": "Version",
"xrayUpdates": "Xray Updates",
"xraySwitchClickDesk": "Choose carefully, as older versions may not be compatible with current configurations.",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "Detener",
"restartXray": "Reiniciar",
"startXray": "Iniciar",
"stopXrayDisabledHint": "Xray no se está ejecutando",
"stopXrayConfirmTitle": "¿Detener Xray?",
"stopXrayConfirmContent": "Detener Xray interrumpirá de inmediato todas las conexiones proxy. ¿Seguro que quieres continuar?",
"stopXrayConfirmOk": "Detener Xray",
"xraySwitch": "Versión",
"xrayUpdates": "Actualizaciones de Xray",
"xraySwitchClickDesk": "Elige sabiamente, ya que las versiones anteriores pueden no ser compatibles con las configuraciones actuales.",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "توقف",
"restartXray": "راه‌اندازی مجدد",
"startXray": "شروع",
"stopXrayDisabledHint": "Xray در حال اجرا نیست",
"stopXrayConfirmTitle": "Xray متوقف شود؟",
"stopXrayConfirmContent": "توقف Xray بلافاصله همه اتصالات پراکسی را قطع می‌کند. آیا مطمئن هستید؟",
"stopXrayConfirmOk": "توقف Xray",
"xraySwitch": "‌نسخه",
"xrayUpdates": "به‌روزرسانی‌های Xray",
"xraySwitchClickDesk": "لطفا بادقت انتخاب کنید. درصورت انتخاب نسخه قدیمی‌تر، امکان ناهماهنگی با پیکربندی فعلی وجود دارد",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "Hentikan",
"restartXray": "Mulai ulang",
"startXray": "Mulai",
"stopXrayDisabledHint": "Xray tidak berjalan",
"stopXrayConfirmTitle": "Hentikan Xray?",
"stopXrayConfirmContent": "Menghentikan Xray akan langsung memutus semua koneksi proxy. Yakin ingin melanjutkan?",
"stopXrayConfirmOk": "Hentikan Xray",
"xraySwitch": "Versi",
"xrayUpdates": "Pembaruan Xray",
"xraySwitchClickDesk": "Pilih dengan hati-hati, karena versi yang lebih lama mungkin tidak kompatibel dengan konfigurasi saat ini.",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "停止",
"restartXray": "再起動",
"startXray": "開始",
"stopXrayDisabledHint": "Xray は実行されていません",
"stopXrayConfirmTitle": "Xray を停止しますか?",
"stopXrayConfirmContent": "Xray を停止すると、すべてのプロキシ接続が直ちに切断されます。続行しますか?",
"stopXrayConfirmOk": "Xray を停止",
"xraySwitch": "バージョン",
"xrayUpdates": "Xrayの更新",
"xraySwitchClickDesk": "慎重に選択してください。古いバージョンは現在の設定と互換性がない可能性があります。",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "Parar",
"restartXray": "Reiniciar",
"startXray": "Iniciar",
"stopXrayDisabledHint": "O Xray não está em execução",
"stopXrayConfirmTitle": "Parar o Xray?",
"stopXrayConfirmContent": "Parar o Xray interromperá imediatamente todas as conexões de proxy. Tem certeza de que deseja continuar?",
"stopXrayConfirmOk": "Parar o Xray",
"xraySwitch": "Versão",
"xrayUpdates": "Atualizações do Xray",
"xraySwitchClickDesk": "Escolha com cuidado, pois versões mais antigas podem não ser compatíveis com as configurações atuais.",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "Стоп",
"restartXray": "Перезапуск",
"startXray": "Запуск",
"stopXrayDisabledHint": "Xray не запущен",
"stopXrayConfirmTitle": "Остановить Xray?",
"stopXrayConfirmContent": "Остановка Xray немедленно разорвёт все прокси-соединения. Продолжить?",
"stopXrayConfirmOk": "Остановить Xray",
"xraySwitch": "Выбор версии",
"xrayUpdates": "Обновления Xray",
"xraySwitchClickDesk": "Важно: старые версии могут не поддерживать текущие настройки",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "Zastaviť",
"restartXray": "Reštartovať",
"startXray": "Spustiť",
"stopXrayDisabledHint": "Xray nebeží",
"stopXrayConfirmTitle": "Zastaviť Xray?",
"stopXrayConfirmContent": "Zastavenie Xray okamžite preruší všetky proxy spojenia. Naozaj chcete pokračovať?",
"stopXrayConfirmOk": "Zastaviť Xray",
"xraySwitch": "Verzia",
"xrayUpdates": "Aktualizácie Xray",
"xraySwitchClickDesk": "Verziu vyberajte opatrne. Staršie verzie nemusia podporovať aktuálnu konfiguráciu.",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "Durdur",
"restartXray": "Yeniden Başlat",
"startXray": "Başlat",
"stopXrayDisabledHint": "Xray çalışmıyor",
"stopXrayConfirmTitle": "Xray durdurulsun mu?",
"stopXrayConfirmContent": "Xray'i durdurmak tüm proxy bağlantılarını anında keser. Devam etmek istediğinizden emin misiniz?",
"stopXrayConfirmOk": "Xray'i durdur",
"xraySwitch": "Sürüm",
"xrayUpdates": "Xray Güncellemeleri",
"xraySwitchClickDesk": "Dikkatli seçin, eski sürümler mevcut yapılandırmalarla uyumlu olmayabilir.",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "Стоп",
"restartXray": "Перезапуск",
"startXray": "Запуск",
"stopXrayDisabledHint": "Xray не запущено",
"stopXrayConfirmTitle": "Зупинити Xray?",
"stopXrayConfirmContent": "Зупинка Xray негайно розірве всі проксі-з'єднання. Продовжити?",
"stopXrayConfirmOk": "Зупинити Xray",
"xraySwitch": "Версія",
"xrayUpdates": "Оновлення Xray",
"xraySwitchClickDesk": "Вибирайте уважно, оскільки старіші версії можуть бути несумісними з поточними конфігураціями.",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "Dừng",
"restartXray": "Khởi động lại",
"startXray": "Khởi động",
"stopXrayDisabledHint": "Xray không chạy",
"stopXrayConfirmTitle": "Dừng Xray?",
"stopXrayConfirmContent": "Dừng Xray sẽ ngắt ngay lập tức mọi kết nối proxy. Bạn có chắc muốn tiếp tục?",
"stopXrayConfirmOk": "Dừng Xray",
"xraySwitch": "Phiên bản",
"xrayUpdates": "Cập nhật Xray",
"xraySwitchClickDesk": "Hãy lựa chọn thận trọng, vì các phiên bản cũ có thể không tương thích với các cấu hình hiện tại.",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "停止",
"restartXray": "重启",
"startXray": "启动",
"stopXrayDisabledHint": "Xray 未运行",
"stopXrayConfirmTitle": "停止 Xray?",
"stopXrayConfirmContent": "停止 Xray 将立即中断所有代理连接。确定要继续吗?",
"stopXrayConfirmOk": "停止 Xray",
"xraySwitch": "版本",
"xrayUpdates": "Xray 更新",
"xraySwitchClickDesk": "请谨慎选择,因为较旧版本可能与当前配置不兼容",
+5
View File
@@ -151,6 +151,11 @@
"xrayStatus": "Xray",
"stopXray": "停止",
"restartXray": "重新啟動",
"startXray": "啟動",
"stopXrayDisabledHint": "Xray 未執行",
"stopXrayConfirmTitle": "停止 Xray?",
"stopXrayConfirmContent": "停止 Xray 將立即中斷所有代理連線。確定要繼續嗎?",
"stopXrayConfirmOk": "停止 Xray",
"xraySwitch": "版本",
"xrayUpdates": "Xray 更新",
"xraySwitchClickDesk": "請謹慎選擇,因為較舊版本可能與當前配置不相容",