mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 18:24:10 +00:00
* 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.
519 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|