mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 00:55:01 +03:00
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:
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user