Files
l-ui/frontend/src/pages/xray/basics/BasicsTab.tsx
T
Muse 5031a70ccb P9(4): remove local-core live-test trio — outbound speed test + egress probe, restart/stop xray endpoints
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)
2026-10-21 16:35:53 +00:00

500 lines
16 KiB
TypeScript

import { useCallback } from 'react';
import { onNumber } from '@/utils/onNumber';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
import {
BarChartOutlined,
ClockCircleOutlined,
FileTextOutlined,
ReloadOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './BasicsTab.css';
import {
ACCESS_LOG,
ERROR_LOG,
LOG_LEVELS,
MASK_ADDRESS,
ROUTING_DOMAIN_STRATEGIES,
} from './constants';
interface BasicsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
onResetDefault: () => void;
}
export default function BasicsTab({
templateSettings,
setTemplateSettings,
onResetDefault,
}: BasicsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
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],
);
const setLevel0 = useCallback(
(field: string, value: number | null) =>
mutate((tt) => {
if (!tt.policy) tt.policy = {};
if (!tt.policy.levels) tt.policy.levels = {};
if (!tt.policy.levels['0']) tt.policy.levels['0'] = {};
if (value === null || value === undefined) {
delete tt.policy.levels['0'][field];
} else {
tt.policy.levels['0'][field] = value;
}
}),
[mutate],
);
const metricsCfg = (templateSettings as { metrics?: { tag?: string; listen?: string } } | null)
?.metrics;
const setMetrics = useCallback(
(field: 'tag' | 'listen', value: string) =>
mutate((tt) => {
const node = tt as {
metrics?: { tag?: string; listen?: string };
stats?: Record<string, unknown>;
};
const m: { tag?: string; listen?: string } = { ...(node.metrics ?? {}) };
if (value.trim() === '') {
delete m[field];
} else {
m[field] = value.trim();
}
if (!m.listen && !m.tag) {
delete node.metrics;
} else {
node.metrics = m;
// xray-core's metrics handler needs a stats object to populate.
if (!node.stats) node.stats = {};
}
}),
[mutate],
);
function confirmResetDefault() {
modal.confirm({
title: t('pages.settings.resetDefaultConfig'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => onResetDefault(),
});
}
const freedomStrategy =
(
templateSettings?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct')
?.settings as { domainStrategy?: string } | undefined
)?.domainStrategy ?? 'AsIs';
const directFreedomOutbound = templateSettings?.outbounds?.find(
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
);
const directHappyEyeballs = (() => {
const sockopt = (
directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined
)?.sockopt;
const raw = sockopt?.happyEyeballs;
if (raw == null || typeof raw !== 'object') return null;
const parsed = HappyEyeballsSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
})();
const setDirectHappyEyeballs = useCallback(
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
mutate((tt) => {
if (!tt.outbounds) tt.outbounds = [];
let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
if (idx < 0) {
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
idx = tt.outbounds.length - 1;
}
const ob = tt.outbounds[idx];
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
if (next == null) {
delete sockopt.happyEyeballs;
} else {
sockopt.happyEyeballs = next;
}
if (Object.keys(sockopt).length === 0) {
delete stream.sockopt;
} else {
stream.sockopt = sockopt;
}
if (Object.keys(stream).length === 0) {
delete ob.streamSettings;
} else {
ob.streamSettings = stream;
}
});
},
[mutate],
);
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
const log = (templateSettings?.log || {}) as Record<string, unknown>;
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record<string, unknown>;
const items = [
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.generalConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.FreedomStrategy')}
description={t('pages.xray.FreedomStrategyDesc')}
paddings="small"
control={
<Select
value={freedomStrategy}
style={{ width: '100%' }}
options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
onChange={(next) =>
mutate((tt) => {
if (!tt.outbounds) tt.outbounds = [];
const idx = tt.outbounds.findIndex(
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
);
if (idx < 0) {
tt.outbounds.push({
protocol: 'freedom',
tag: 'direct',
settings: { domainStrategy: next },
});
} else {
const ob = tt.outbounds[idx];
ob.settings = (ob.settings || {}) as Record<string, unknown>;
(ob.settings as Record<string, unknown>).domainStrategy = next;
}
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.FreedomHappyEyeballs')}
description={t('pages.xray.FreedomHappyEyeballsDesc')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs != null}
onChange={(checked) => {
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
}}
/>
}
/>
{directHappyEyeballs != null && (
<>
<SettingListItem
title={t('pages.inbounds.form.tryDelayMs')}
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
paddings="small"
control={
<InputNumber
min={0}
style={{ width: '100%' }}
value={directHappyEyeballs.tryDelayMs}
placeholder="150"
onChange={onNumber((v) =>
setDirectHappyEyeballs({
...directHappyEyeballs,
tryDelayMs: v,
}),
)}
/>
}
/>
<SettingListItem
title={t('pages.inbounds.form.prioritizeIPv6')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs.prioritizeIPv6}
onChange={(checked) =>
setDirectHappyEyeballs({
...directHappyEyeballs,
prioritizeIPv6: checked,
})
}
/>
}
/>
</>
)}
<SettingListItem
title={t('pages.xray.RoutingStrategy')}
description={t('pages.xray.RoutingStrategyDesc')}
paddings="small"
control={
<Select
value={routingStrategy}
style={{ width: '100%' }}
options={ROUTING_DOMAIN_STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(next) =>
mutate((tt) => {
if (tt.routing) tt.routing.domainStrategy = next;
})
}
/>
}
/>
</>
),
},
{
key: '2',
label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
children: (
<>
{[
['statsInboundUplink', t('pages.xray.statsInboundUplink')],
['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
].map(([field, label]) => (
<SettingListItem
key={field}
title={label}
paddings="small"
control={
<Switch
checked={!!policy[field]}
onChange={(checked) =>
mutate((tt) => {
if (!tt.policy) tt.policy = {};
if (!tt.policy.system) tt.policy.system = {};
tt.policy.system[field] = checked;
})
}
/>
}
/>
))}
<SettingListItem
title={t('pages.xray.metricsListen')}
description={t('pages.xray.metricsListenDesc')}
paddings="small"
control={
<Input
value={metricsCfg?.listen ?? ''}
onChange={(e) => setMetrics('listen', e.target.value)}
placeholder="127.0.0.1:11111"
/>
}
/>
<SettingListItem
title={t('pages.xray.metricsTag')}
paddings="small"
control={
<Input
value={metricsCfg?.tag ?? ''}
onChange={(e) => setMetrics('tag', e.target.value)}
placeholder="metrics_out"
/>
}
/>
</>
),
},
{
key: 'connection',
label: catTabLabel(<ClockCircleOutlined />, t('pages.xray.connectionLimits'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.connectionLimitsDesc')}
/>
<SettingListItem
title={t('pages.xray.connIdle')}
description={t('pages.xray.connIdleDesc')}
paddings="small"
control={
<InputNumber
value={typeof level0.connIdle === 'number' ? level0.connIdle : undefined}
min={0}
style={{ width: '100%' }}
placeholder="300"
suffix={t('pages.xray.seconds')}
onChange={(v) => setLevel0('connIdle', v as number | null)}
/>
}
/>
<SettingListItem
title={t('pages.xray.bufferSize')}
description={t('pages.xray.bufferSizeDesc')}
paddings="small"
control={
<InputNumber
value={typeof level0.bufferSize === 'number' ? level0.bufferSize : undefined}
min={0}
style={{ width: '100%' }}
placeholder={t('pages.xray.bufferSizePlaceholder')}
suffix="KB"
onChange={(v) => setLevel0('bufferSize', v as number | null)}
/>
}
/>
</>
),
},
{
key: '3',
label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.logConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.logLevel')}
description={t('pages.xray.logLevelDesc')}
paddings="small"
control={
<Select
value={(log.loglevel as string) || 'warning'}
style={{ width: '100%' }}
options={LOG_LEVELS.map((s) => ({ value: s, label: s }))}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.loglevel = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.accessLog')}
description={t('pages.xray.accessLogDesc')}
paddings="small"
control={
<Select
value={(log.access as string) || ''}
style={{ width: '100%' }}
options={ACCESS_LOG.map((s) => ({ value: s, label: s }))}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.access = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.errorLog')}
description={t('pages.xray.errorLogDesc')}
paddings="small"
control={
<Select
value={(log.error as string) || ''}
style={{ width: '100%' }}
options={[
{ value: '', label: t('empty') },
...ERROR_LOG.map((s) => ({ value: s, label: s })),
]}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.error = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.maskAddress')}
description={t('pages.xray.maskAddressDesc')}
paddings="small"
control={
<Select
value={(log.maskAddress as string) || ''}
style={{ width: '100%' }}
options={[
{ value: '', label: t('empty') },
...MASK_ADDRESS.map((s) => ({ value: s, label: s })),
]}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.maskAddress = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.dnsLog')}
description={t('pages.xray.dnsLogDesc')}
paddings="small"
control={
<Switch
checked={!!log.dnsLog}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.dnsLog = v;
})
}
/>
}
/>
</>
),
},
{
key: 'reset',
label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
children: (
<Space style={{ padding: '0 20px' }}>
<Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
{t('pages.settings.resetDefaultConfig')}
</Button>
</Space>
),
},
];
return (
<>
{modalContextHolder}
<Tabs defaultActiveKey="1" items={items} />
</>
);
}