Files
3x-ui-agent/frontend/src/pages/index/IndexPage.tsx
T
Muse a654a96c38
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
页面层(用户真机截图发现前两轮漏裁):
- 设置: 摘除 Telegram机器人/邮件菜单与页面, 及页内残留入口(通知页/外部流量/LDAP/2FA设置页)
- Xray配置: 摘除负载均衡/DNS/高级配置菜单与页面; 基础配置(出站+路由)完好
- 总览: 摘除 Xray更新弹窗(VersionModal+GeodataSection)全部入口, 版本号改为静态展示
- 13个locale同步摘除461个已删功能专属翻译键(逐键核查前后端引用为零; i18n-dead-keys测试全绿)

Xray更新后端(经调查确认仅服务本机bin/xray, 与agent下发/订阅无关):
- 删除 getXrayVersion/installXray/updateGeofile 路由与handler(访问返回404)
- 删除 ServerService 对应实现与校验和函数; 同步api-docs/monitor白名单/openapi

本机核心监管下线(设计: 主控永不运行本机xray, 入站全在agent):
- XrayService.RestartXray 降级为no-op(一次性Warning说明日志); StopXray无进程时幂等返回
- web.go 摘除启动拉起本机核心/CheckXrayRunning看门狗(1s)/ApplyPendingRestart cron(30s)/XrayTrafficJob/CheckClientIpJob 注册
- ApplyPendingRestart 降级为消费即清, 其测试更新为新语义
- XrayMetricsService.Sample 短路: 不再每2s探本机/debug/vars, 状态优雅置为不可用
- 配置生成(纯JSON)/agent链路/订阅/入站CRUD未动; 状态读数优雅归零(stop/Unknown)

注: TestGetXrayConfig_EnabledClientsStillEmitted 在基线945b3a5同样失败(既有问题, 非本轮引入)
2026-10-20 23:39:38 +08:00

374 lines
15 KiB
TypeScript

import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
import {
CopyOutlined,
CloudDownloadOutlined,
DashboardOutlined,
DatabaseOutlined,
HddOutlined,
SwapOutlined,
} from '@ant-design/icons';
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
import {
USAGE_CRIT_COLOR,
USAGE_CRIT_PERCENT,
USAGE_WARN_COLOR,
USAGE_WARN_PERCENT,
} from '@/models/status';
import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
import { setMessageInstance } from '@/utils/messageBus';
import OverviewActionBar from './OverviewActionBar';
import VitalTile from './VitalTile';
import ThroughputCard from './ThroughputCard';
import ConnectionsCard from './ConnectionsCard';
import SystemStrip from './SystemStrip';
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
import type { PanelUpdateInfo } from './PanelUpdateModal';
const JsonEditor = lazy(() => import('@/components/form/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 AmneziaWGLogModal = lazy(() => import('./AmneziaWGLogModal'));
import './IndexPage.css';
export default function IndexPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { status, fetched, fetchError, refresh } = useStatusQuery();
const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [accessLogEnable, setAccessLogEnable] = useState(false);
const [devChannelEnable, setDevChannelEnable] = useState(false);
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
currentVersion: '',
latestVersion: '',
updateAvailable: false,
});
const basePath = window.X_UI_BASE_PATH || '';
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 [amneziawgLogsOpen, setAmneziawgLogsOpen] = useState(false);
const [configTextOpen, setConfigTextOpen] = useState(false);
const [configText, setConfigText] = useState('');
const [loading, setLoading] = useState(false);
const [loadingTip, setLoadingTip] = useState(t('loading'));
const history = useOverviewHistory(status, fetched && !fetchError);
useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
).then((msg) => {
if (msg?.success && msg.obj) {
setAccessLogEnable(!!msg.obj.accessLogEnable);
setDevChannelEnable(!!msg.obj.devChannelEnable);
}
});
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
});
}, []);
const displayVersion = useMemo(
() => window.X_UI_CUR_VER || panelUpdateInfo.currentVersion || '?',
[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]);
async function handleChannelChange(dev: boolean) {
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
if (!res?.success) return;
setDevChannelEnable(dev);
const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
}
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(t('copied'));
}
function downloadConfig() {
FileManager.downloadTextFile(configText, 'config.json');
}
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
const totalDisk = status.disk.total;
const freeDisk = Math.max(0, totalDisk - status.disk.current);
const health = useMemo(() => {
const items = [
{ name: t('pages.index.cpu'), value: status.cpu.percent },
{ name: t('pages.index.memory'), value: status.mem.percent },
{ name: t('pages.index.swap'), value: status.swap.percent },
{ name: t('pages.index.storage'), value: status.disk.percent },
];
const list = (xs: typeof items) => xs.map((i) => `${i.name} ${i.value.toFixed(0)}%`).join(', ');
const crit = items.filter((i) => i.value >= USAGE_CRIT_PERCENT);
if (crit.length)
return {
text: t('pages.index.healthCritical', { list: list(crit) }),
color: USAGE_CRIT_COLOR,
};
const warm = items.filter((i) => i.value >= USAGE_WARN_PERCENT);
if (warm.length)
return { text: t('pages.index.healthWarm', { list: list(warm) }), color: USAGE_WARN_COLOR };
return null;
}, [status, t]);
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<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" />
) : fetchError ? (
<Result
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={
<Button type="primary" onClick={refresh}>
{t('refresh')}
</Button>
}
/>
) : (
<div className="ov-page">
<OverviewActionBar
status={status}
isMobile={isMobile}
accessLogEnable={accessLogEnable}
panelVersion={displayVersion}
latestVersion={panelUpdateInfo.latestVersion}
updateAvailable={panelUpdateInfo.updateAvailable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenLogs={() => setLogsOpen(true)}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)}
onOpenConfig={openConfig}
onOpenBackup={() => setBackupOpen(true)}
onOpenSystemHistory={() => setSysHistoryOpen(true)}
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
/>
{health && (
<div className="ov-health" style={{ color: health.color }}>
<span className="ov-health-mark" />
{health.text}
</div>
)}
<hr className="ov-rule" />
<div className="ov-vitals">
<VitalTile
icon={<DashboardOutlined />}
label={t('pages.index.cpu')}
percent={status.cpu.percent}
statusColor={status.cpu.color}
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`}
data={history.series.cpu}
isMobile={isMobile}
/>
<VitalTile
icon={<DatabaseOutlined />}
label={t('pages.index.memory')}
percent={status.mem.percent}
statusColor={status.mem.color}
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
data={history.series.mem}
isMobile={isMobile}
/>
<VitalTile
icon={<SwapOutlined />}
label={t('pages.index.swap')}
percent={status.swap.percent}
statusColor={status.swap.color}
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
data={history.series.swap}
isMobile={isMobile}
/>
<VitalTile
icon={<HddOutlined />}
label={t('pages.index.storage')}
percent={status.disk.percent}
statusColor={status.disk.color}
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
data={history.series.diskUsage}
isMobile={isMobile}
/>
</div>
<div className="ov-mid">
<ThroughputCard
status={status}
up={history.series.netUp}
down={history.series.netDown}
labels={history.labels}
isMobile={isMobile}
/>
<ConnectionsCard
status={status}
tcp={history.series.tcpCount}
udp={history.series.udpCount}
labels={history.labels}
isMobile={isMobile}
/>
</div>
<SystemStrip
status={status}
showIp={showIp}
onToggleIp={() => setShowIp((v) => !v)}
/>
</div>
)}
</Spin>
</Layout.Content>
</Layout>
<LazyMount when={panelUpdateOpen}>
<PanelUpdateModal
open={panelUpdateOpen}
info={panelUpdateInfo}
devChannelEnable={devChannelEnable}
onChannelChange={handleChannelChange}
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={amneziawgLogsOpen}>
<AmneziaWGLogModal open={amneziawgLogsOpen} onClose={() => setAmneziawgLogsOpen(false)} />
</LazyMount>
<LazyMount when={configTextOpen}>
<Modal
open={configTextOpen}
title={t('pages.index.config')}
width={isMobile ? '100%' : 900}
style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : { top: 20 }}
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' : 'calc(100vh - 220px)'}
maxHeight={isMobile ? '70vh' : 'calc(100vh - 220px)'}
readOnly
/>
</Modal>
</LazyMount>
</Layout>
</ConfigProvider>
);
}