Per user decision 2026-10-21 (reverses the earlier keep call in PANEL-LITE-SPEC.md, now amended): the lite panel never runs a local core, so measuring/probing from the panel host is meaningless. Backend: - delete service/outbound probe implementation (probe_http.go and its tests) and the TestOutbound/TestOutbounds batch prober; outbound traffic accounting (AddTraffic/GetOutboundsTraffic/Reset) unchanged - drop POST /panel/api/xray/testOutbound + testOutbounds routes and the outboundTestUrl setting (get/update plumbing removed; stored rows in existing DBs are inert) - drop POST /panel/api/server/restartXrayService + stopXrayService routes/handlers and the node-sync allowlist entry; the service-level Stop/RestartXrayService stay for internal import/restore flows - node-sync allowlist contract test updated to the new inventory Frontend: - remove test buttons/mode switch/test-all from outbounds tab, card list and table columns; delete TestResultPopover and CountryPill; drop the egress/country/latency columns fed by probe results - remove outboundTestUrl state and save plumbing from useXraySetting and the Basics tab field; drop OutboundTestResult schemas - delete the 15 now-dead i18n keys from all 13 locales (incl. the restart/stop keys orphaned since P6/P7 — i18n-dead-keys test green) - tests: drop the test-URL hook test, re-probe the loopback index mapping through Move-to-top, regenerate API types (gen)
287 lines
8.9 KiB
TypeScript
287 lines
8.9 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLocation } from 'react-router';
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Card,
|
|
Col,
|
|
ConfigProvider,
|
|
FloatButton,
|
|
Layout,
|
|
message,
|
|
Result,
|
|
Row,
|
|
Space,
|
|
Spin,
|
|
} from 'antd';
|
|
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
import { useXraySetting } from '@/hooks/useXraySetting';
|
|
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
|
import AppSidebar from '@/layouts/AppSidebar';
|
|
import { setMessageInstance } from '@/utils/messageBus';
|
|
|
|
import { BasicsTab } from './basics';
|
|
import { propagateOutboundTagRename } from './basics/helpers';
|
|
import { RoutingTab } from './routing';
|
|
import { OutboundsTab } from './outbounds';
|
|
import {
|
|
cleanupOrphanedBalancerLoopbacks,
|
|
ensureMissingBalancerLoopbacks,
|
|
detectBalancerCycles,
|
|
} from './balancers/balancer-loopback';
|
|
import { WarpModal, NordModal, PiaModal } from './overrides';
|
|
import './XrayPage.css';
|
|
|
|
const SECTION_SLUGS = ['basic', 'routing', 'outbound'];
|
|
|
|
export default function XrayPage() {
|
|
const { t } = useTranslation();
|
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
|
const { isMobile } = useMediaQuery();
|
|
const [messageApi, messageContextHolder] = message.useMessage();
|
|
useEffect(() => {
|
|
setMessageInstance(messageApi);
|
|
}, [messageApi]);
|
|
const xs = useXraySetting();
|
|
const {
|
|
fetched,
|
|
spinning,
|
|
saveDisabled,
|
|
fetchError,
|
|
xraySetting,
|
|
setXraySetting,
|
|
templateSettings,
|
|
setTemplateSettings,
|
|
inboundTags,
|
|
clientReverseTags,
|
|
subscriptionOutbounds,
|
|
subscriptionOutboundTags,
|
|
outboundsTraffic,
|
|
fetchAll,
|
|
resetOutboundsTraffic,
|
|
saveAll,
|
|
resetToDefault,
|
|
} = xs;
|
|
|
|
const [warpOpen, setWarpOpen] = useState(false);
|
|
const [nordOpen, setNordOpen] = useState(false);
|
|
const [piaOpen, setPiaOpen] = useState(false);
|
|
const location = useLocation();
|
|
const pathSection =
|
|
location.pathname === '/outbound'
|
|
? 'outbound'
|
|
: location.pathname === '/routing'
|
|
? 'routing'
|
|
: '';
|
|
const sectionSlug = pathSection || location.hash.replace(/^#/, '');
|
|
const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
|
|
|
|
const mutate = useCallback(
|
|
(mutator: (next: XraySettingsValue) => void) => {
|
|
setTemplateSettings((prev) => {
|
|
if (!prev) return prev;
|
|
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
|
|
mutator(clone);
|
|
return clone;
|
|
});
|
|
},
|
|
[setTemplateSettings],
|
|
);
|
|
|
|
function onAddOutbound(outbound: Record<string, unknown>) {
|
|
mutate((tt) => {
|
|
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
|
tt.outbounds.push(outbound as never);
|
|
});
|
|
}
|
|
function onResetOutbound(payload: {
|
|
index: number;
|
|
outbound: Record<string, unknown>;
|
|
oldTag?: string;
|
|
newTag?: string;
|
|
}) {
|
|
mutate((tt) => {
|
|
if (!tt.outbounds || payload.index < 0) return;
|
|
tt.outbounds[payload.index] = payload.outbound as never;
|
|
if (payload.oldTag && payload.newTag) {
|
|
propagateOutboundTagRename(tt, payload.oldTag, payload.newTag);
|
|
}
|
|
});
|
|
}
|
|
function onRemoveOutboundByTag(tag: string) {
|
|
mutate((tt) => {
|
|
if (!tt.outbounds) return;
|
|
const idx = tt.outbounds.findIndex((o) => o?.tag === tag);
|
|
if (idx >= 0) tt.outbounds.splice(idx, 1);
|
|
});
|
|
}
|
|
function onRemoveOutboundByIndex(index: number) {
|
|
mutate((tt) => {
|
|
if (tt.outbounds && index >= 0) tt.outbounds.splice(index, 1);
|
|
});
|
|
}
|
|
function onRemoveRoutingRules(payload: { prefix: string }) {
|
|
mutate((tt) => {
|
|
const rules = tt.routing?.rules;
|
|
if (!Array.isArray(rules)) return;
|
|
tt.routing!.rules = rules.filter((r) => !r?.outboundTag?.startsWith?.(payload.prefix));
|
|
});
|
|
}
|
|
|
|
function onSaveAll() {
|
|
try {
|
|
JSON.parse(xraySetting);
|
|
} catch (e) {
|
|
messageApi.error(`Template JSON: ${(e as Error).message}`);
|
|
return;
|
|
}
|
|
if (templateSettings) {
|
|
const clone = JSON.parse(JSON.stringify(templateSettings));
|
|
ensureMissingBalancerLoopbacks(clone);
|
|
cleanupOrphanedBalancerLoopbacks(clone);
|
|
const cycles = detectBalancerCycles(clone);
|
|
if (cycles.length > 0) {
|
|
const names = cycles.map((c) => c.join(' → ')).join(', ');
|
|
messageApi.error(t('pages.xray.balancer.balancerFallbackCycle') + ' (' + names + ')');
|
|
return;
|
|
}
|
|
const serialized = JSON.stringify(clone, null, 2);
|
|
setXraySetting(serialized);
|
|
setTemplateSettings(clone);
|
|
}
|
|
saveAll();
|
|
}
|
|
|
|
const scrollTarget = () => document.getElementById('content-layout') || window;
|
|
|
|
const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
|
|
|
const sectionBody = (() => {
|
|
switch (activeSection) {
|
|
case 'routing':
|
|
return (
|
|
<RoutingTab
|
|
templateSettings={templateSettings}
|
|
setTemplateSettings={setTemplateSettings}
|
|
inboundTags={inboundTags}
|
|
clientReverseTags={clientReverseTags}
|
|
subscriptionOutboundTags={subscriptionOutboundTags}
|
|
isMobile={isMobile}
|
|
/>
|
|
);
|
|
case 'outbound':
|
|
return (
|
|
<OutboundsTab
|
|
templateSettings={templateSettings}
|
|
setTemplateSettings={setTemplateSettings}
|
|
outboundsTraffic={outboundsTraffic}
|
|
inboundTags={inboundTags}
|
|
subscriptionOutbounds={subscriptionOutbounds}
|
|
subscriptionOutboundTags={subscriptionOutboundTags}
|
|
isMobile={isMobile}
|
|
onResetTraffic={resetOutboundsTraffic}
|
|
onShowWarp={() => setWarpOpen(true)}
|
|
onShowNord={() => setNordOpen(true)}
|
|
onShowPia={() => setPiaOpen(true)}
|
|
onRefreshXrayData={fetchAll}
|
|
/>
|
|
);
|
|
default:
|
|
return (
|
|
<BasicsTab
|
|
templateSettings={templateSettings}
|
|
setTemplateSettings={setTemplateSettings}
|
|
onResetDefault={resetToDefault}
|
|
/>
|
|
);
|
|
}
|
|
})();
|
|
|
|
return (
|
|
<ConfigProvider theme={antdThemeConfig}>
|
|
{messageContextHolder}
|
|
<Layout className={pageClass}>
|
|
<AppSidebar />
|
|
|
|
<Layout className="content-shell">
|
|
<Layout.Content id="content-layout" className="content-area">
|
|
<Spin
|
|
spinning={spinning || !fetched}
|
|
delay={200}
|
|
description={t('loading')}
|
|
size="large"
|
|
>
|
|
{!fetched ? (
|
|
<div className="loading-spacer" />
|
|
) : fetchError ? (
|
|
<Result
|
|
status="error"
|
|
title={t('somethingWentWrong')}
|
|
subTitle={fetchError}
|
|
extra={
|
|
<Button type="primary" onClick={fetchAll}>
|
|
{t('check')}
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
|
|
<Col span={24}>
|
|
<Card hoverable>
|
|
<Row className="header-row">
|
|
<Col xs={24} sm={14} className="header-actions">
|
|
<Space>
|
|
<Button type="primary" disabled={saveDisabled} onClick={onSaveAll}>
|
|
{t('pages.xray.save')}
|
|
</Button>
|
|
</Space>
|
|
</Col>
|
|
<Col xs={24} sm={10} className="header-info">
|
|
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
|
|
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
</Col>
|
|
|
|
<Col span={24}>
|
|
<Card hoverable>{sectionBody}</Card>
|
|
</Col>
|
|
</Row>
|
|
)}
|
|
</Spin>
|
|
</Layout.Content>
|
|
</Layout>
|
|
|
|
<WarpModal
|
|
open={warpOpen}
|
|
templateSettings={templateSettings}
|
|
onClose={() => setWarpOpen(false)}
|
|
onAddOutbound={onAddOutbound}
|
|
onResetOutbound={onResetOutbound}
|
|
onRemoveOutbound={onRemoveOutboundByTag}
|
|
/>
|
|
<NordModal
|
|
open={nordOpen}
|
|
templateSettings={templateSettings}
|
|
onClose={() => setNordOpen(false)}
|
|
onAddOutbound={onAddOutbound}
|
|
onResetOutbound={onResetOutbound}
|
|
onRemoveOutbound={onRemoveOutboundByIndex}
|
|
onRemoveRoutingRules={onRemoveRoutingRules}
|
|
/>
|
|
<PiaModal
|
|
open={piaOpen}
|
|
templateSettings={templateSettings}
|
|
onClose={() => setPiaOpen(false)}
|
|
onAddOutbound={onAddOutbound}
|
|
onResetOutbound={onResetOutbound}
|
|
/>
|
|
</Layout>
|
|
</ConfigProvider>
|
|
);
|
|
}
|