3x-ui/frontend/src/pages/index/IndexPage.tsx
Sanaei 09df07ddf5
perf(frontend): lazy-load modals + split heavy vendor chunks (#4501)
* perf(frontend): lazy-load modals on inbounds / clients / index pages

Modals on the three list pages were imported statically, so the JS +
CSS for every form, info, qr, log, backup, metrics, system-history,
version, and config-text modal sat in the initial bundle even though
they're only needed after a click.

Converted those imports to React.lazy() and gated each modal with a
new LazyMount helper that mounts on first open and keeps the component
mounted thereafter so AntD close animations still play.

Build now emits a dedicated chunk per modal — InboundFormModal at
66 kB (13 kB gzipped) and InboundInfoModal at 23 kB (4 kB gzipped)
are the largest, totalling roughly 150 kB of code that no longer
parses on first paint. Profiler measured the inbounds-page React
render tree drop from ~444 ms to ~254 ms on a prod build.

* perf(frontend): split codemirror / jalali / otpauth into lazy vendor chunks

Heavy libs (codemirror, persian-calendar-suite, otpauth) and antd's
rc-/cssinjs transitive deps used to fall into the catch-all `vendor`
chunk and load with every entry point. Give them their own manualChunks
groups so they only load with the lazy modal/page that needs them.

Initial vendor (catch-all) drops from 1293 kB / 408 kB gzip to
76 kB / 27 kB gzip; codemirror (408 kB / 131 kB gzip) is now on the
JsonEditor lazy path instead of the inbounds/clients/index initial load.
2026-05-23 18:56:11 +02:00

519 lines
19 KiB
TypeScript

import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Col,
ConfigProvider,
Layout,
message,
Modal,
Row,
Space,
Spin,
Tag,
Tooltip,
} from 'antd';
import {
BarsOutlined,
ControlOutlined,
CloudServerOutlined,
CloudDownloadOutlined,
CloudUploadOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
AreaChartOutlined,
GlobalOutlined,
SwapOutlined,
EyeOutlined,
EyeInvisibleOutlined,
ThunderboltOutlined,
DesktopOutlined,
DatabaseOutlined,
ForkOutlined,
CopyOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { useTheme } from '@/hooks/useTheme';
import { useStatus } from '@/hooks/useStatus';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic';
import LazyMount from '@/components/LazyMount';
import { setMessageInstance } from '@/utils/messageBus';
import StatusCard from './StatusCard';
import XrayStatusCard from './XrayStatusCard';
import type { PanelUpdateInfo } from './PanelUpdateModal';
const JsonEditor = lazy(() => import('@/components/JsonEditor'));
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
const LogModal = lazy(() => import('./LogModal'));
const BackupModal = lazy(() => import('./BackupModal'));
const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
const XrayLogModal = lazy(() => import('./XrayLogModal'));
const VersionModal = lazy(() => import('./VersionModal'));
import '@/styles/page-cards.css';
import './IndexPage.css';
export default function IndexPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { status, fetched, refresh } = useStatus();
const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const [ipLimitEnable, setIpLimitEnable] = useState(false);
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
currentVersion: '',
latestVersion: '',
updateAvailable: false,
});
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const [showIp, setShowIp] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [backupOpen, setBackupOpen] = useState(false);
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
const [versionOpen, setVersionOpen] = useState(false);
const [configTextOpen, setConfigTextOpen] = useState(false);
const [configText, setConfigText] = useState('');
const [loading, setLoading] = useState(false);
const [loadingTip, setLoadingTip] = useState(t('loading'));
useEffect(() => {
HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
});
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
});
}, []);
const displayVersion = useMemo(
() => panelUpdateInfo.currentVersion || window.X_UI_CUR_VER || '?',
[panelUpdateInfo.currentVersion],
);
const setBusy = useCallback(
({ busy, tip }: { busy: boolean; tip?: string }) => {
setLoading(busy);
if (tip) setLoadingTip(tip);
},
[],
);
const stopXray = useCallback(async () => {
await HttpUtil.post('/panel/api/server/stopXrayService');
await refresh();
}, [refresh]);
const restartXray = useCallback(async () => {
await HttpUtil.post('/panel/api/server/restartXrayService');
await refresh();
}, [refresh]);
function openPanelVersion() {
if (panelUpdateInfo.updateAvailable) {
setPanelUpdateOpen(true);
} else {
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
}
}
function openTelegram() {
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
}
async function openConfig() {
setLoading(true);
try {
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
if (!msg?.success) return;
setConfigText(JSON.stringify(msg.obj, null, 2));
setConfigTextOpen(true);
} finally {
setLoading(false);
}
}
async function copyConfig() {
const ok = await ClipboardManager.copyText(configText || '');
if (ok) messageApi.success('Copied');
}
function downloadConfig() {
FileManager.downloadTextFile(configText, 'config.json');
}
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<Layout className="content-shell">
<Layout.Content className="content-area">
<Spin
spinning={loading || !fetched}
delay={200}
description={loading ? loadingTip : t('loading')}
size="large"
>
{!fetched ? (
<div className="loading-spacer" />
) : (
<Row gutter={[isMobile ? 8 : 16, 12]}>
<Col span={24}>
<StatusCard status={status} isMobile={isMobile} />
</Col>
<Col xs={24} lg={12}>
<XrayStatusCard
status={status}
isMobile={isMobile}
ipLimitEnable={ipLimitEnable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenLogs={() => setLogsOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('menu.link')}
hoverable
actions={[
<Space className="action" key="logs" onClick={() => setLogsOpen(true)}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.logs')}</span>}
</Space>,
<Space className="action" key="config" onClick={openConfig}>
<ControlOutlined />
{!isMobile && <span>{t('pages.index.config')}</span>}
</Space>,
<Space className="action" key="backup" onClick={() => setBackupOpen(true)}>
<CloudServerOutlined />
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
</Space>,
]}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<span>3X-UI</span>
{isMobile && displayVersion && (
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
{panelUpdateInfo.updateAvailable
? `v${panelUpdateInfo.latestVersion}`
: `v${displayVersion}`}
</Tag>
)}
</Space>
}
hoverable
actions={[
<Space className="action" key="tg" onClick={openTelegram}>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
className="tg-icon"
aria-hidden="true"
>
<path d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
</svg>
{!isMobile && <span>@XrayUI</span>}
</Space>,
<Space
key="panel-version"
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
onClick={openPanelVersion}
>
<CloudDownloadOutlined />
{!isMobile && (
<span>
{panelUpdateInfo.updateAvailable
? `${t('update')} ${panelUpdateInfo.latestVersion}`
: `v${displayVersion}`}
</span>
)}
</Space>,
]}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('pages.index.charts')}
hoverable
actions={[
<Space
className="action"
key="sys-history"
onClick={() => setSysHistoryOpen(true)}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
</Space>,
<Space
className="action"
key="xray-metrics"
onClick={() => setXrayMetricsOpen(true)}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
</Space>,
]}
/>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.operationHours')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<CustomStatistic
title="Xray"
value={TimeFormatter.formatSecond(status.appStats.uptime)}
prefix={<ThunderboltOutlined />}
/>
</Col>
<Col span={12}>
<CustomStatistic
title="OS"
value={TimeFormatter.formatSecond(status.uptime)}
prefix={<DesktopOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('usage')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<CustomStatistic
title={t('pages.index.memory')}
value={SizeFormatter.sizeFormat(status.appStats.mem)}
prefix={<DatabaseOutlined />}
/>
</Col>
<Col span={12}>
<CustomStatistic
title={t('pages.index.threads')}
value={status.appStats.threads}
prefix={<ForkOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.overallSpeed')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<CustomStatistic
title={t('pages.index.upload')}
value={SizeFormatter.sizeFormat(status.netIO.up)}
prefix={<ArrowUpOutlined />}
suffix="/s"
/>
</Col>
<Col span={12}>
<CustomStatistic
title={t('pages.index.download')}
value={SizeFormatter.sizeFormat(status.netIO.down)}
prefix={<ArrowDownOutlined />}
suffix="/s"
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.totalData')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<CustomStatistic
title={t('pages.index.sent')}
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
prefix={<CloudUploadOutlined />}
/>
</Col>
<Col span={12}>
<CustomStatistic
title={t('pages.index.received')}
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
prefix={<CloudDownloadOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('pages.index.ipAddresses')}
hoverable
extra={
<Tooltip
title={t('pages.index.toggleIpVisibility')}
placement={isMobile ? 'topRight' : 'top'}
>
{showIp ? (
<EyeOutlined
className="ip-toggle-icon"
onClick={() => setShowIp(false)}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
onClick={() => setShowIp(true)}
/>
)}
</Tooltip>
}
>
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
<Col span={isMobile ? 24 : 12}>
<CustomStatistic
title="IPv4"
value={status.publicIP.ipv4}
prefix={<GlobalOutlined />}
/>
</Col>
<Col span={isMobile ? 24 : 12}>
<CustomStatistic
title="IPv6"
value={status.publicIP.ipv6}
prefix={<GlobalOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.connectionCount')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<CustomStatistic
title="TCP"
value={status.tcpCount}
prefix={<SwapOutlined />}
/>
</Col>
<Col span={12}>
<CustomStatistic
title="UDP"
value={status.udpCount}
prefix={<SwapOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<LazyMount when={panelUpdateOpen}>
<PanelUpdateModal
open={panelUpdateOpen}
info={panelUpdateInfo}
onClose={() => setPanelUpdateOpen(false)}
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={logsOpen}>
<LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
</LazyMount>
<LazyMount when={backupOpen}>
<BackupModal
open={backupOpen}
basePath={basePath}
onClose={() => setBackupOpen(false)}
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={sysHistoryOpen}>
<SystemHistoryModal
open={sysHistoryOpen}
status={status}
onClose={() => setSysHistoryOpen(false)}
/>
</LazyMount>
<LazyMount when={xrayMetricsOpen}>
<XrayMetricsModal open={xrayMetricsOpen} onClose={() => setXrayMetricsOpen(false)} />
</LazyMount>
<LazyMount when={xrayLogsOpen}>
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
</LazyMount>
<LazyMount when={versionOpen}>
<VersionModal
open={versionOpen}
status={status}
onClose={() => setVersionOpen(false)}
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={configTextOpen}>
<Modal
open={configTextOpen}
title={t('pages.index.config')}
width={isMobile ? '100%' : 900}
style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : undefined}
onCancel={() => setConfigTextOpen(false)}
footer={[
<Button
key="download"
onClick={downloadConfig}
size={isMobile ? 'small' : 'middle'}
icon={<CloudDownloadOutlined />}
>
{isMobile ? 'Download' : 'config.json'}
</Button>,
<Button
key="copy"
type="primary"
onClick={copyConfig}
size={isMobile ? 'small' : 'middle'}
icon={<CopyOutlined />}
>
Copy
</Button>,
]}
>
<JsonEditor
value={configText}
onChange={setConfigText}
minHeight={isMobile ? '300px' : '420px'}
maxHeight={isMobile ? '500px' : '720px'}
readOnly
/>
</Modal>
</LazyMount>
</Layout>
</ConfigProvider>
);
}