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)
500 lines
16 KiB
TypeScript
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} />
|
|
</>
|
|
);
|
|
}
|