- 第22条 L-UI:登录页品牌名、侧栏 brand-text(收起态 L)、抽屉品牌、 usePageTitle 默认标题(HTML 标题)、13 个语言文件里用户可见串中的 3X-UI 字样(更新说明×2、TG 版本行)一并改为 L-UI;仓库/二进制/ 服务名/路径/内部包名不动 - 第18条:formatPanelVersion 对 V/A 系显示版本原样返回(新增单测); PanelUpdateModal 与首页动作条改用 display* 字段判更新与显示 - 第14条:首页运行时间卡片删除 Xray 半边与分隔线,只留 OS - 第21条:节点表列宽 操作190→150/已启用80→64/客户端180→150/ 延迟100→84/心跳120→96、地址列 330;agent 行 IPv4·IPv6 单行 (浅点分隔、沿用可见性 class),传统行不变 - 第20条:agent 行云朵按钮与移动端菜单改用「更新 Agent」文案; NodesPage 对 agent 行用无渠道选择的简单确认(传统行不变); schemas 增 agentUpdateState/Error/Target - 第19条:节点编辑页(agent)拉 /panel/api/agent/install/:id 展示 完整安装命令(master 可编辑、含 token 时完整命令+复制按钮); 无 token 时展示无 token 命令+agent.json 路径提示,不报错不空白 - i18n 新增 4 键(updateAgent/updateAgentConfirmContent/ agentInstallCommand/agentTokenNotStored)补齐全部 13 个语言文件 (zh-CN 中文、其余英文),tsc 干净、panel-version 测试全过、 i18n 失败仍为既有 forwarding 键集问题(第10条)零新增
333 lines
13 KiB
TypeScript
333 lines
13 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 BackupModal = lazy(() => import('./BackupModal'));
|
|
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 history = useOverviewHistory(status, fetched && !fetchError);
|
|
|
|
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 [backupOpen, setBackupOpen] = useState(false);
|
|
const [panelUpdateOpen, setPanelUpdateOpen] = 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<{ devChannelEnable?: boolean }>('/panel/api/setting/defaultSettings').then(
|
|
(msg) => {
|
|
if (msg?.success && msg.obj) {
|
|
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);
|
|
}, []);
|
|
|
|
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
|
|
isMobile={isMobile}
|
|
panelVersion={displayVersion}
|
|
latestVersion={
|
|
panelUpdateInfo.channel === 'dev'
|
|
? panelUpdateInfo.latestVersion
|
|
: panelUpdateInfo.displayLatestVersion || panelUpdateInfo.latestVersion
|
|
}
|
|
updateAvailable={
|
|
panelUpdateInfo.channel === 'dev'
|
|
? panelUpdateInfo.updateAvailable
|
|
: (panelUpdateInfo.displayUpdateAvailable ??
|
|
panelUpdateInfo.updateAvailable)
|
|
}
|
|
onOpenConfig={openConfig}
|
|
onOpenBackup={() => setBackupOpen(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={backupOpen}>
|
|
<BackupModal
|
|
open={backupOpen}
|
|
basePath={basePath}
|
|
onClose={() => setBackupOpen(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)' } : { 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>
|
|
);
|
|
}
|