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());
});
});