Files
3x-ui/frontend/src/pages/index/PanelUpdateModal.tsx
T
MHSanaei bc309ed9f8 refactor(frontend): replace axios with the native Fetch API
Drop the axios (and qs) dependencies in favor of a native fetch wrapper.
axios only ever handled same-origin JSON/form calls, a CSRF header, a 401
redirect, and a 403-retry, all of which the platform now provides directly.

- New src/api/http-init.ts (replaces axios-init.ts) reimplements the
  request/response interceptors on fetch: base-path prefixing,
  X-Requested-With, same-origin credentials, the CSRF token on unsafe
  methods, a single 403 retry with token refresh, and the 401
  redirect-and-latch. A small encodeForm() reproduces qs's
  arrayFormat:'repeat' encoding, so the request wire format is unchanged.
- HttpUtil (src/utils/index.ts) keeps its public signatures and the Msg
  envelope, so the ~49 API call sites are untouched. HttpOptions is now
  hand-rolled instead of extending AxiosRequestConfig.
- PanelUpdateModal drops its lone direct axios.get in favor of HttpUtil.get
  with { silent, timeout }.
- Add tests for the fetch core (CSRF header, form/JSON/FormData bodies,
  base-path prefix, 403 retry, 401 redirect, tolerant body parse) and for
  HttpUtil's envelope unwrap / toast / error mapping; this logic was
  previously untested.
- Remove the vendor-axios manualChunks branch and the qs type shim, and
  reword stale "axios" mentions in docs and route comments.
2026-07-08 01:09:18 +02:00

190 lines
5.6 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Modal, Switch, Tag } from 'antd';
import { CloudDownloadOutlined } from '@ant-design/icons';
import { HttpUtil, PromiseUtil } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import type { PanelUpdateStatus } from '@/generated/types';
import './PanelUpdateModal.css';
type UpdateOutcome = 'success' | 'failed' | 'timeout';
export interface PanelUpdateInfo {
channel?: string;
currentVersion: string;
latestVersion: string;
currentCommit?: string;
latestCommit?: string;
updateAvailable: boolean;
}
interface BusyEvent {
busy: boolean;
tip?: string;
}
interface PanelUpdateModalProps {
open: boolean;
info: PanelUpdateInfo;
devChannelEnable?: boolean;
onChannelChange?: (dev: boolean) => void | Promise<void>;
onClose: () => void;
onBusy: (e: BusyEvent) => void;
}
export default function PanelUpdateModal({
open,
info,
devChannelEnable,
onChannelChange,
onClose,
onBusy,
}: PanelUpdateModalProps) {
const { t } = useTranslation();
const [modal, contextHolder] = Modal.useModal();
const [channelBusy, setChannelBusy] = useState(false);
const isDev = info.channel === 'dev';
async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
await PromiseUtil.sleep(5000);
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
try {
const msg = await HttpUtil.get<PanelUpdateStatus>(
'/panel/api/server/getUpdateStatus',
undefined,
{ silent: true, timeout: 2000 },
);
const status = msg?.obj ?? undefined;
if (status?.runId === expectedRunId) {
if (status.state === 'success') return 'success';
if (status.state === 'failed') return 'failed';
}
} catch {
/* still restarting */
}
await PromiseUtil.sleep(2000);
}
return 'timeout';
}
async function handleChannel(checked: boolean) {
if (!onChannelChange) return;
setChannelBusy(true);
try {
await onChannelChange(checked);
} finally {
setChannelBusy(false);
}
}
function updatePanel() {
modal.confirm({
title: t('pages.index.panelUpdateDialog'),
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', info.latestVersion || ''),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
const baseTip = t('pages.index.dontRefresh');
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
onClose();
onBusy({ busy: true, tip });
const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
if (!result?.success) {
onBusy({ busy: false });
return;
}
const outcome = await pollUpdateStatus(result.obj?.runId ?? '');
onBusy({ busy: false });
if (outcome === 'success') {
await PromiseUtil.sleep(800);
window.location.reload();
return;
}
modal[outcome === 'failed' ? 'error' : 'warning']({
title: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedTitle' : 'pages.index.panelUpdateUnknownTitle'),
content: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedDesc' : 'pages.index.panelUpdateUnknownDesc'),
okText: t('refresh'),
onOk: () => window.location.reload(),
});
},
});
}
return (
<>
{contextHolder}
<Modal
open={open}
title={t('pages.index.updatePanel')}
footer={null}
onCancel={onClose}
>
{info.updateAvailable && (
<Alert
type="warning"
className="mb-12"
title={t('pages.index.panelUpdateDesc')}
showIcon
/>
)}
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.devChannel')}</span>
<Switch
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
</div>
</div>
{devChannelEnable && (
<Alert
type="info"
className="mb-12"
title={t('pages.index.devChannelWarning')}
showIcon
/>
)}
<div className="version-list">
<div className="version-list-item">
<span>{isDev ? t('pages.index.currentCommit') : t('pages.index.currentPanelVersion')}</span>
{isDev ? (
<Tag color="green">{info.currentCommit || '?'}</Tag>
) : (
<Tag color="green">{formatPanelVersion(window.X_UI_CUR_VER || info.currentVersion) || '?'}</Tag>
)}
</div>
{info.updateAvailable ? (
<div className="version-list-item">
<span>{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}</span>
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
</div>
) : (
<div className="version-list-item">
<span>{t('pages.index.panelUpToDate')}</span>
<Tag color="green">{t('pages.index.panelUpToDate')}</Tag>
</div>
)}
</div>
<div className="actions-row">
<Button
type="primary"
disabled={!info.updateAvailable}
onClick={updatePanel}
icon={<CloudDownloadOutlined />}
>
{t('pages.index.updatePanel')}
</Button>
</div>
</Modal>
</>
);
}