mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 15:47:14 +00:00
Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262)
* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint
TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.
Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.
Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.
oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.
The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.
Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.
Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
cast, which hid the optional chain from ESLint and would throw on a
null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
accessible but oxlint cannot evaluate it, so it gets a scoped disable.
* chore(docs): replace Prettier with oxfmt
oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.
The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)
The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.
.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.
oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.
* style(frontend): adopt oxfmt and format src
frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.
Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.
Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.
Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.
* ci: enforce formatting in CI and make verify
Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.
Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.
Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.
No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.
* ci: trigger CI on Makefile changes
The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.
* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components
`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.
Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.
Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.
Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.
* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard
Addresses the review on #6262.
The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.
The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.
The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.
Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
hand-written lint logic in the repo is no longer the least covered
file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
CI gate in this PR while the hook only ran the linter, so a commit
could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
and make msw-worker-check byte-compare stay safe even if oxfmt is
invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
both accept JSONC, so relocating them was unnecessary.
Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
This commit is contained in:
@@ -30,7 +30,11 @@ import { propagateOutboundTagRename } from './basics/helpers';
|
||||
import { RoutingTab } from './routing';
|
||||
import { OutboundsTab } from './outbounds';
|
||||
import { BalancersTab } from './balancers';
|
||||
import { cleanupOrphanedBalancerLoopbacks, ensureMissingBalancerLoopbacks, detectBalancerCycles } from './balancers/balancer-loopback';
|
||||
import {
|
||||
cleanupOrphanedBalancerLoopbacks,
|
||||
ensureMissingBalancerLoopbacks,
|
||||
detectBalancerCycles,
|
||||
} from './balancers/balancer-loopback';
|
||||
import { DnsTab } from './dns';
|
||||
import { WarpModal, NordModal } from './overrides';
|
||||
import './XrayPage.css';
|
||||
@@ -44,7 +48,9 @@ export default function XrayPage() {
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
useEffect(() => {
|
||||
setMessageInstance(messageApi);
|
||||
}, [messageApi]);
|
||||
const xs = useXraySetting();
|
||||
const {
|
||||
fetched,
|
||||
@@ -79,7 +85,12 @@ export default function XrayPage() {
|
||||
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const pathSection = location.pathname === '/outbound' ? 'outbound' : location.pathname === '/routing' ? 'routing' : '';
|
||||
const pathSection =
|
||||
location.pathname === '/outbound'
|
||||
? 'outbound'
|
||||
: location.pathname === '/routing'
|
||||
? 'routing'
|
||||
: '';
|
||||
const sectionSlug = pathSection || location.hash.replace(/^#/, '');
|
||||
const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
|
||||
|
||||
@@ -111,7 +122,12 @@ export default function XrayPage() {
|
||||
tt.outbounds.push(outbound as never);
|
||||
});
|
||||
}
|
||||
function onResetOutbound(payload: { index: number; outbound: Record<string, unknown>; oldTag?: string; newTag?: string }) {
|
||||
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;
|
||||
@@ -146,10 +162,14 @@ export default function XrayPage() {
|
||||
if (!tpl) return '';
|
||||
try {
|
||||
switch (advSettings) {
|
||||
case 'inboundSettings': return JSON.stringify(tpl.inbounds || [], null, 2);
|
||||
case 'outboundSettings': return JSON.stringify(tpl.outbounds || [], null, 2);
|
||||
case 'routingRuleSettings': return JSON.stringify(tpl.routing?.rules || [], null, 2);
|
||||
default: return '';
|
||||
case 'inboundSettings':
|
||||
return JSON.stringify(tpl.inbounds || [], null, 2);
|
||||
case 'outboundSettings':
|
||||
return JSON.stringify(tpl.outbounds || [], null, 2);
|
||||
case 'routingRuleSettings':
|
||||
return JSON.stringify(tpl.routing?.rules || [], null, 2);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
@@ -259,10 +279,7 @@ export default function XrayPage() {
|
||||
);
|
||||
case 'dns':
|
||||
return (
|
||||
<DnsTab
|
||||
templateSettings={templateSettings}
|
||||
setTemplateSettings={setTemplateSettings}
|
||||
/>
|
||||
<DnsTab templateSettings={templateSettings} setTemplateSettings={setTemplateSettings} />
|
||||
);
|
||||
case 'advanced':
|
||||
return (
|
||||
@@ -312,7 +329,12 @@ export default function XrayPage() {
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={spinning || !fetched} delay={200} description={t('loading')} size="large">
|
||||
<Spin
|
||||
spinning={spinning || !fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
size="large"
|
||||
>
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
@@ -320,7 +342,11 @@ export default function XrayPage() {
|
||||
status="error"
|
||||
title={t('somethingWentWrong')}
|
||||
subTitle={fetchError}
|
||||
extra={<Button type="primary" onClick={fetchAll}>{t('check')}</Button>}
|
||||
extra={
|
||||
<Button type="primary" onClick={fetchAll}>
|
||||
{t('check')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
|
||||
@@ -343,9 +369,7 @@ export default function XrayPage() {
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Card hoverable>
|
||||
{sectionBody}
|
||||
</Card>
|
||||
<Card hoverable>{sectionBody}</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
@@ -9,10 +9,7 @@ import type { Path } from 'react-hook-form';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import {
|
||||
BalancerFormSchema,
|
||||
type BalancerFormValues,
|
||||
} from '@/schemas/xray';
|
||||
import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray';
|
||||
import {
|
||||
BalancerStrategyTypeSchema,
|
||||
type BalancerStrategyType,
|
||||
@@ -95,7 +92,10 @@ export default function BalancerFormModal({
|
||||
);
|
||||
|
||||
const cycleInfo = useMemo(() => {
|
||||
const rules = (templateSettings?.routing?.rules || []) as Array<{ inboundTag?: string[]; balancerTag?: string }>;
|
||||
const rules = (templateSettings?.routing?.rules || []) as Array<{
|
||||
inboundTag?: string[];
|
||||
balancerTag?: string;
|
||||
}>;
|
||||
const resolveLoopback = (tag: string): string | null => {
|
||||
for (const r of rules) {
|
||||
if (Array.isArray(r.inboundTag) && r.inboundTag.includes(tag) && r.balancerTag) {
|
||||
@@ -135,9 +135,8 @@ export default function BalancerFormModal({
|
||||
const wouldCreateCycle = !!cycleInfo[fallbackTag];
|
||||
|
||||
const fallbackOptions = useMemo(() => {
|
||||
const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> = [
|
||||
{ value: '', label: `(${t('none')})` },
|
||||
];
|
||||
const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> =
|
||||
[{ value: '', label: `(${t('none')})` }];
|
||||
for (const tg of outboundTags) {
|
||||
options.push({ value: tg, label: tg });
|
||||
}
|
||||
@@ -146,10 +145,14 @@ export default function BalancerFormModal({
|
||||
options.push({
|
||||
value: tg,
|
||||
disabled: !!cycle,
|
||||
title: cycle ? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag }) : undefined,
|
||||
title: cycle
|
||||
? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag })
|
||||
: undefined,
|
||||
label: (
|
||||
<span>
|
||||
<Tag color="blue" style={{ marginRight: 4 }}>{t('pages.xray.rules.balancer')}</Tag>
|
||||
<Tag color="blue" style={{ marginRight: 4 }}>
|
||||
{t('pages.xray.rules.balancer')}
|
||||
</Tag>
|
||||
{tg}
|
||||
</span>
|
||||
),
|
||||
@@ -215,13 +218,16 @@ export default function BalancerFormModal({
|
||||
const errorMessage = fieldState.error?.message
|
||||
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
|
||||
: '';
|
||||
const showDuplicate = !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
|
||||
const showDuplicate =
|
||||
!errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('pages.xray.balancer.tag')}
|
||||
required
|
||||
validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''}
|
||||
help={errorMessage || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
|
||||
help={
|
||||
errorMessage || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')
|
||||
}
|
||||
hasFeedback
|
||||
>
|
||||
<Input
|
||||
@@ -286,7 +292,10 @@ export default function BalancerFormModal({
|
||||
<FormField
|
||||
name={['settings', 'maxRTT']}
|
||||
label={t('pages.xray.balancer.maxRtt')}
|
||||
transform={{ input: (v) => v ?? '', output: (v) => (typeof v === 'string' && v ? v : undefined) }}
|
||||
transform={{
|
||||
input: (v) => v ?? '',
|
||||
output: (v) => (typeof v === 'string' && v ? v : undefined),
|
||||
}}
|
||||
>
|
||||
<Input placeholder="e.g. 1s" />
|
||||
</FormField>
|
||||
@@ -295,7 +304,13 @@ export default function BalancerFormModal({
|
||||
label={t('pages.xray.balancer.tolerance')}
|
||||
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
|
||||
>
|
||||
<InputNumber min={0} max={1} step={0.01} placeholder="0.01 = 1%" style={{ width: '100%' }} />
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
placeholder="0.01 = 1%"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.xray.balancer.baselines')}>
|
||||
<Button
|
||||
@@ -311,9 +326,22 @@ export default function BalancerFormModal({
|
||||
value={b}
|
||||
aria-label={t('pages.xray.balancer.baselines')}
|
||||
placeholder="e.g. 1s"
|
||||
onChange={(e) => methods.setValue('settings.baselines', baselines.map((x, i) => (i === idx ? e.target.value : x)))}
|
||||
onChange={(e) =>
|
||||
methods.setValue(
|
||||
'settings.baselines',
|
||||
baselines.map((x, i) => (i === idx ? e.target.value : x)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.baselines', baselines.filter((_, i) => i !== idx))}>
|
||||
<InputAddon
|
||||
ariaLabel={t('remove')}
|
||||
onClick={() =>
|
||||
methods.setValue(
|
||||
'settings.baselines',
|
||||
baselines.filter((_, i) => i !== idx),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
@@ -325,7 +353,12 @@ export default function BalancerFormModal({
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => methods.setValue('settings.costs', [...costs, { regexp: false, match: '', value: 1 }])}
|
||||
onClick={() =>
|
||||
methods.setValue('settings.costs', [
|
||||
...costs,
|
||||
{ regexp: false, match: '', value: 1 },
|
||||
])
|
||||
}
|
||||
/>
|
||||
{costs.map((c, idx) => (
|
||||
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
|
||||
@@ -334,22 +367,47 @@ export default function BalancerFormModal({
|
||||
aria-label={t('pages.xray.balancer.costRegexp')}
|
||||
checkedChildren="re"
|
||||
unCheckedChildren="lit"
|
||||
onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
|
||||
onChange={(v) =>
|
||||
methods.setValue(
|
||||
'settings.costs',
|
||||
costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
value={c.match}
|
||||
aria-label={t('pages.xray.balancer.costMatch')}
|
||||
placeholder="tag pattern"
|
||||
onChange={(e) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
|
||||
onChange={(e) =>
|
||||
methods.setValue(
|
||||
'settings.costs',
|
||||
costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputNumber
|
||||
value={c.value}
|
||||
aria-label={t('pages.xray.balancer.costValue')}
|
||||
placeholder="weight"
|
||||
style={{ width: 100 }}
|
||||
onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
|
||||
onChange={(v) =>
|
||||
methods.setValue(
|
||||
'settings.costs',
|
||||
costs.map((x, i) =>
|
||||
i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.costs', costs.filter((_, i) => i !== idx))}>
|
||||
<InputAddon
|
||||
ariaLabel={t('remove')}
|
||||
onClick={() =>
|
||||
methods.setValue(
|
||||
'settings.costs',
|
||||
costs.filter((_, i) => i !== idx),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Empty, Modal, Select, Space, Table, Tabs, Tag, Tooltip, message } from 'antd';
|
||||
import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SyncOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
RadarChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import BalancerFormModal from './BalancerFormModal';
|
||||
@@ -112,7 +132,10 @@ export default function BalancersTab({
|
||||
|
||||
const otherTags = useMemo(() => {
|
||||
if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
|
||||
return rows.filter((b) => b.key !== editingIndex).map((b) => b.tag).filter(Boolean);
|
||||
return rows
|
||||
.filter((b) => b.key !== editingIndex)
|
||||
.map((b) => b.tag)
|
||||
.filter(Boolean);
|
||||
}, [rows, editingIndex]);
|
||||
|
||||
const balancerTags = useMemo(() => {
|
||||
@@ -138,7 +161,11 @@ export default function BalancersTab({
|
||||
const [liveStatus, setLiveStatus] = useState<Record<string, BalancerLiveStatus>>({});
|
||||
const [liveLoading, setLiveLoading] = useState(false);
|
||||
const liveTags = useMemo(
|
||||
() => rows.map((r) => r.tag).filter(Boolean).join(','),
|
||||
() =>
|
||||
rows
|
||||
.map((r) => r.tag)
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
[rows],
|
||||
);
|
||||
|
||||
@@ -149,7 +176,11 @@ export default function BalancersTab({
|
||||
}
|
||||
setLiveLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/balancerStatus', { tags: liveTags }, { silent: true });
|
||||
const msg = await HttpUtil.post(
|
||||
'/panel/api/xray/balancerStatus',
|
||||
{ tags: liveTags },
|
||||
{ silent: true },
|
||||
);
|
||||
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
|
||||
setLiveStatus(msg.obj as Record<string, BalancerLiveStatus>);
|
||||
}
|
||||
@@ -227,9 +258,7 @@ export default function BalancersTab({
|
||||
propagateBalancerTagRename(tt, oldTag, wire.tag);
|
||||
}
|
||||
|
||||
const oldTarget = isBalancerLoopbackTag(oldFallback)
|
||||
? (oldFallback.slice(4))
|
||||
: null;
|
||||
const oldTarget = isBalancerLoopbackTag(oldFallback) ? oldFallback.slice(4) : null;
|
||||
|
||||
if (oldTarget && oldTarget !== form.fallbackTag) {
|
||||
removeBalancerLoopbackIfOrphaned(tt, oldTarget);
|
||||
@@ -250,7 +279,9 @@ export default function BalancersTab({
|
||||
.filter((b) => b.tag !== deletedTag && b.fallbackTag === lbTag)
|
||||
.map((b) => b.tag);
|
||||
if (dependents.length > 0) {
|
||||
messageApi.error(t('pages.xray.balancer.balancerDeleteInUse', { names: dependents.join(', ') }));
|
||||
messageApi.error(
|
||||
t('pages.xray.balancer.balancerDeleteInUse', { names: dependents.join(', ') }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const impact = templateSettings
|
||||
@@ -262,11 +293,12 @@ export default function BalancersTab({
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => mutate((tt) => {
|
||||
const tag = tt.routing?.balancers?.[idx]?.tag ?? '';
|
||||
removeBalancerLoopback(tt, tag);
|
||||
applyBalancerDeletion(tt, idx);
|
||||
}),
|
||||
onOk: () =>
|
||||
mutate((tt) => {
|
||||
const tag = tt.routing?.balancers?.[idx]?.tag ?? '';
|
||||
removeBalancerLoopback(tt, tag);
|
||||
applyBalancerDeletion(tt, idx);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -281,7 +313,13 @@ export default function BalancersTab({
|
||||
<span className="row-index">{index + 1}</span>
|
||||
<div className={!isMobile ? 'action-buttons' : ''}>
|
||||
{!isMobile && (
|
||||
<Button aria-label={t('edit')} shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
|
||||
<Button
|
||||
aria-label={t('edit')}
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(index)}
|
||||
/>
|
||||
)}
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
@@ -342,7 +380,13 @@ export default function BalancersTab({
|
||||
</Tag>
|
||||
)),
|
||||
},
|
||||
{ title: 'Fallback', dataIndex: 'displayFallbackTag', key: 'displayFallbackTag', align: 'center', width: 160 },
|
||||
{
|
||||
title: 'Fallback',
|
||||
dataIndex: 'displayFallbackTag',
|
||||
key: 'displayFallbackTag',
|
||||
align: 'center',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: t('pages.xray.balancerLive'),
|
||||
key: 'live',
|
||||
@@ -357,8 +401,13 @@ export default function BalancersTab({
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const resolve = (tag: string) => isBalancerLoopbackTag(tag) ? resolveLoopbackFallback(templateSettings!, tag) : tag;
|
||||
const picked = live.override ? resolve(live.override) : live.selected?.[0] ? resolve(live.selected[0]) : record.displayFallbackTag;
|
||||
const resolve = (tag: string) =>
|
||||
isBalancerLoopbackTag(tag) ? resolveLoopbackFallback(templateSettings!, tag) : tag;
|
||||
const picked = live.override
|
||||
? resolve(live.override)
|
||||
: live.selected?.[0]
|
||||
? resolve(live.selected[0])
|
||||
: record.displayFallbackTag;
|
||||
const tooltipText = live.override
|
||||
? resolve(live.override)
|
||||
: (live.selected || []).map(resolve).join(', ');
|
||||
@@ -379,20 +428,26 @@ export default function BalancersTab({
|
||||
const resolvedFB = record.displayFallbackTag;
|
||||
let options = overrideOptions;
|
||||
if (resolvedFB && !outboundTags.includes(resolvedFB)) {
|
||||
options = [...overrideOptions, {
|
||||
value: resolvedFB,
|
||||
label: (
|
||||
<span>
|
||||
<Tag color="blue" style={{ marginRight: 4 }}>{t('pages.xray.rules.balancer')}</Tag>
|
||||
{resolvedFB}
|
||||
</span>
|
||||
),
|
||||
}];
|
||||
options = [
|
||||
...overrideOptions,
|
||||
{
|
||||
value: resolvedFB,
|
||||
label: (
|
||||
<span>
|
||||
<Tag color="blue" style={{ marginRight: 4 }}>
|
||||
{t('pages.xray.rules.balancer')}
|
||||
</Tag>
|
||||
{resolvedFB}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
const rawOverride = live?.override || undefined;
|
||||
const resolvedOverride = rawOverride && isBalancerLoopbackTag(rawOverride)
|
||||
? resolveLoopbackFallback(templateSettings!, rawOverride)
|
||||
: rawOverride;
|
||||
const resolvedOverride =
|
||||
rawOverride && isBalancerLoopbackTag(rawOverride)
|
||||
? resolveLoopbackFallback(templateSettings!, rawOverride)
|
||||
: rawOverride;
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
@@ -424,7 +479,11 @@ export default function BalancersTab({
|
||||
{t('pages.xray.Balancers')}
|
||||
</Button>
|
||||
<Tooltip title={t('pages.xray.balancerLiveRefresh')}>
|
||||
<Button aria-label={t('pages.xray.balancerLiveRefresh')} icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
|
||||
<Button
|
||||
aria-label={t('pages.xray.balancerLiveRefresh')}
|
||||
icon={<SyncOutlined spin={liveLoading} />}
|
||||
onClick={refreshLive}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
@@ -449,17 +508,18 @@ export default function BalancersTab({
|
||||
items={[
|
||||
{
|
||||
key: 'balancers',
|
||||
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.xray.tabBalancerSettings'), isMobile),
|
||||
label: catTabLabel(
|
||||
<DeploymentUnitOutlined />,
|
||||
t('pages.xray.tabBalancerSettings'),
|
||||
isMobile,
|
||||
),
|
||||
children: balancerSettingsTab,
|
||||
},
|
||||
{
|
||||
key: 'observatory',
|
||||
label: catTabLabel(<RadarChartOutlined />, t('pages.xray.tabObservatory'), isMobile),
|
||||
children: (
|
||||
<ObservatorySettingsTab
|
||||
templateSettings={templateSettings}
|
||||
mutate={mutate}
|
||||
/>
|
||||
<ObservatorySettingsTab templateSettings={templateSettings} mutate={mutate} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -33,7 +33,11 @@ function SelectorTags({ tags }: { tags: string[] }) {
|
||||
return (
|
||||
<>
|
||||
{tags.map((sel) => (
|
||||
<Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4, marginBottom: 4 }}>
|
||||
<Tag
|
||||
key={sel}
|
||||
className="info-large-tag"
|
||||
style={{ margin: 0, marginRight: 4, marginBottom: 4 }}
|
||||
>
|
||||
{sel}
|
||||
</Tag>
|
||||
))}
|
||||
@@ -57,16 +61,20 @@ export default function ObservatorySettingsTab({
|
||||
const raw = templateSettings?.burstObservatory;
|
||||
if (raw == null) return null;
|
||||
const merged = { ...BURST_DEFAULTS, ...asObject(raw) } as BurstObservatoryObject;
|
||||
merged.pingConfig = { ...BURST_DEFAULTS.pingConfig, ...asObject(merged.pingConfig) } as PingConfigObject;
|
||||
merged.pingConfig = {
|
||||
...BURST_DEFAULTS.pingConfig,
|
||||
...asObject(merged.pingConfig),
|
||||
} as PingConfigObject;
|
||||
return merged;
|
||||
}, [templateSettings?.burstObservatory]);
|
||||
|
||||
const hasObservatory = observatory != null;
|
||||
const hasBurst = burst != null;
|
||||
const hasMixedObservers = hasObservatory && hasBurst;
|
||||
const activeView = hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings))
|
||||
? 'burstObservatory'
|
||||
: 'observatory';
|
||||
const activeView =
|
||||
hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings))
|
||||
? 'burstObservatory'
|
||||
: 'observatory';
|
||||
|
||||
function patchObservatory(patch: Partial<ObservatoryObject>) {
|
||||
mutate((tt) => {
|
||||
@@ -219,11 +227,7 @@ export default function ObservatorySettingsTab({
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert type="info" showIcon title={t('pages.xray.observatory.autoManaged')} />
|
||||
{hasMixedObservers && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title={t('pages.xray.observatory.mixedLegacy')}
|
||||
/>
|
||||
<Alert type="warning" showIcon title={t('pages.xray.observatory.mixedLegacy')} />
|
||||
)}
|
||||
<div>{activeView === 'observatory' ? observatorySection : burstSection}</div>
|
||||
</Space>
|
||||
|
||||
@@ -28,7 +28,10 @@ export function collectSelectors(list: BalancerObject[]): string[] {
|
||||
|
||||
export function balancerRequiresBurstObservatory(b: BalancerObject): boolean {
|
||||
const type = b.strategy?.type || 'random';
|
||||
return type === 'leastLoad' || ((type === 'random' || type === 'roundRobin') && (b.fallbackTag ?? '').length > 0);
|
||||
return (
|
||||
type === 'leastLoad' ||
|
||||
((type === 'random' || type === 'roundRobin') && (b.fallbackTag ?? '').length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function settingsRequireBurstObservatory(t: XraySettingsValue | null): boolean {
|
||||
@@ -63,7 +66,8 @@ export function syncObservatories(t: XraySettingsValue) {
|
||||
const required = balancers.filter(balancerRequiresBurstObservatory);
|
||||
if (required.length > 0) {
|
||||
delete t.observatory;
|
||||
if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
|
||||
if (!t.burstObservatory)
|
||||
t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
|
||||
(t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([
|
||||
...required,
|
||||
...leastPings,
|
||||
@@ -72,7 +76,8 @@ export function syncObservatories(t: XraySettingsValue) {
|
||||
delete t.burstObservatory;
|
||||
if (leastPings.length > 0) {
|
||||
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
|
||||
(t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
|
||||
(t.observatory as { subjectSelector: string[] }).subjectSelector =
|
||||
collectSelectors(leastPings);
|
||||
} else {
|
||||
delete t.observatory;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ function loopbackMatchesTarget(loopbackTag: string, targetTag: string): boolean
|
||||
}
|
||||
|
||||
function findLoopbackTarget(settings: XraySettingsValue, loopbackTag: string): string | null {
|
||||
const rules = (settings.routing?.rules || []) as Array<{ inboundTag?: string[]; balancerTag?: string }>;
|
||||
const rules = (settings.routing?.rules || []) as Array<{
|
||||
inboundTag?: string[];
|
||||
balancerTag?: string;
|
||||
}>;
|
||||
for (const r of rules) {
|
||||
if (Array.isArray(r.inboundTag) && r.inboundTag.includes(loopbackTag) && r.balancerTag) {
|
||||
return r.balancerTag;
|
||||
@@ -31,10 +34,7 @@ function findLoopbackTarget(settings: XraySettingsValue, loopbackTag: string): s
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLoopbackFallback(
|
||||
settings: XraySettingsValue,
|
||||
fallbackTag: string,
|
||||
): string {
|
||||
export function resolveLoopbackFallback(settings: XraySettingsValue, fallbackTag: string): string {
|
||||
if (!fallbackTag || !isBalancerLoopbackTag(fallbackTag)) return fallbackTag;
|
||||
const target = findLoopbackTarget(settings, fallbackTag);
|
||||
if (target) return target;
|
||||
@@ -45,7 +45,11 @@ export function resolveLoopbackFallback(
|
||||
function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): number {
|
||||
let count = 0;
|
||||
for (const b of (settings.routing?.balancers || []) as Array<{ fallbackTag?: string }>) {
|
||||
if (b.fallbackTag && isBalancerLoopbackTag(b.fallbackTag) && loopbackMatchesTarget(b.fallbackTag, targetTag)) {
|
||||
if (
|
||||
b.fallbackTag &&
|
||||
isBalancerLoopbackTag(b.fallbackTag) &&
|
||||
loopbackMatchesTarget(b.fallbackTag, targetTag)
|
||||
) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@@ -140,7 +144,10 @@ export function propagateBalancerTagRename(
|
||||
const newLbTag = loopbackTagFor(newTag);
|
||||
|
||||
if (Array.isArray(settings.outbounds)) {
|
||||
for (const o of settings.outbounds as Array<{ tag?: string; settings?: { inboundTag?: string } }>) {
|
||||
for (const o of settings.outbounds as Array<{
|
||||
tag?: string;
|
||||
settings?: { inboundTag?: string };
|
||||
}>) {
|
||||
if (o.tag === oldLbTag) o.tag = newLbTag;
|
||||
if (o.settings?.inboundTag === oldLbTag) o.settings.inboundTag = newLbTag;
|
||||
}
|
||||
@@ -163,7 +170,10 @@ export function propagateBalancerTagRename(
|
||||
}
|
||||
|
||||
export function detectBalancerCycles(settings: XraySettingsValue): string[][] {
|
||||
const balancers = (settings.routing?.balancers || []) as Array<{ tag?: string; fallbackTag?: string }>;
|
||||
const balancers = (settings.routing?.balancers || []) as Array<{
|
||||
tag?: string;
|
||||
fallbackTag?: string;
|
||||
}>;
|
||||
const cycles: string[][] = [];
|
||||
|
||||
for (const b of balancers) {
|
||||
@@ -189,7 +199,10 @@ export function detectBalancerCycles(settings: XraySettingsValue): string[][] {
|
||||
}
|
||||
|
||||
export function ensureMissingBalancerLoopbacks(settings: XraySettingsValue): void {
|
||||
const balancers = (settings.routing?.balancers || []) as Array<{ tag?: string; fallbackTag?: string }>;
|
||||
const balancers = (settings.routing?.balancers || []) as Array<{
|
||||
tag?: string;
|
||||
fallbackTag?: string;
|
||||
}>;
|
||||
for (const b of balancers) {
|
||||
if (!b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue;
|
||||
const targetTag = balancerTagFromLoopback(b.fallbackTag);
|
||||
|
||||
@@ -58,38 +58,44 @@ export default function BasicsTab({
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}),
|
||||
(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 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 = {};
|
||||
}
|
||||
}),
|
||||
(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],
|
||||
);
|
||||
|
||||
@@ -104,16 +110,18 @@ export default function BasicsTab({
|
||||
}
|
||||
|
||||
const freedomStrategy =
|
||||
(templateSettings?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct')?.settings as
|
||||
| { domainStrategy?: string }
|
||||
| undefined)?.domainStrategy ?? 'AsIs';
|
||||
(
|
||||
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 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);
|
||||
@@ -178,17 +186,25 @@ export default function BasicsTab({
|
||||
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;
|
||||
}
|
||||
})}
|
||||
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;
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -217,10 +233,12 @@ export default function BasicsTab({
|
||||
style={{ width: '100%' }}
|
||||
value={directHappyEyeballs.tryDelayMs}
|
||||
placeholder="150"
|
||||
onChange={onNumber((v) => setDirectHappyEyeballs({
|
||||
...directHappyEyeballs,
|
||||
tryDelayMs: v,
|
||||
}))}
|
||||
onChange={onNumber((v) =>
|
||||
setDirectHappyEyeballs({
|
||||
...directHappyEyeballs,
|
||||
tryDelayMs: v,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -230,10 +248,12 @@ export default function BasicsTab({
|
||||
control={
|
||||
<Switch
|
||||
checked={directHappyEyeballs.prioritizeIPv6}
|
||||
onChange={(checked) => setDirectHappyEyeballs({
|
||||
...directHappyEyeballs,
|
||||
prioritizeIPv6: checked,
|
||||
})}
|
||||
onChange={(checked) =>
|
||||
setDirectHappyEyeballs({
|
||||
...directHappyEyeballs,
|
||||
prioritizeIPv6: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -248,9 +268,11 @@ export default function BasicsTab({
|
||||
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;
|
||||
})}
|
||||
onChange={(next) =>
|
||||
mutate((tt) => {
|
||||
if (tt.routing) tt.routing.domainStrategy = next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -287,11 +309,13 @@ export default function BasicsTab({
|
||||
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;
|
||||
})}
|
||||
onChange={(checked) =>
|
||||
mutate((tt) => {
|
||||
if (!tt.policy) tt.policy = {};
|
||||
if (!tt.policy.system) tt.policy.system = {};
|
||||
tt.policy.system[field] = checked;
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -386,7 +410,11 @@ export default function BasicsTab({
|
||||
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; })}
|
||||
onChange={(v) =>
|
||||
mutate((tt) => {
|
||||
if (tt.log) tt.log.loglevel = v;
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -399,7 +427,11 @@ export default function BasicsTab({
|
||||
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; })}
|
||||
onChange={(v) =>
|
||||
mutate((tt) => {
|
||||
if (tt.log) tt.log.access = v;
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -411,8 +443,15 @@ export default function BasicsTab({
|
||||
<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; })}
|
||||
options={[
|
||||
{ value: '', label: t('empty') },
|
||||
...ERROR_LOG.map((s) => ({ value: s, label: s })),
|
||||
]}
|
||||
onChange={(v) =>
|
||||
mutate((tt) => {
|
||||
if (tt.log) tt.log.error = v;
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -424,8 +463,15 @@ export default function BasicsTab({
|
||||
<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; })}
|
||||
options={[
|
||||
{ value: '', label: t('empty') },
|
||||
...MASK_ADDRESS.map((s) => ({ value: s, label: s })),
|
||||
]}
|
||||
onChange={(v) =>
|
||||
mutate((tt) => {
|
||||
if (tt.log) tt.log.maskAddress = v;
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -436,7 +482,11 @@ export default function BasicsTab({
|
||||
control={
|
||||
<Switch
|
||||
checked={!!log.dnsLog}
|
||||
onChange={(v) => mutate((tt) => { if (tt.log) tt.log.dnsLog = v; })}
|
||||
onChange={(v) =>
|
||||
mutate((tt) => {
|
||||
if (tt.log) tt.log.dnsLog = v;
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -61,4 +61,8 @@ export const SERVICES_OPTIONS = [
|
||||
|
||||
export const directSettings = { tag: 'direct', protocol: 'freedom' };
|
||||
export const blockedSettings = { tag: 'blocked', protocol: 'blackhole', settings: {} };
|
||||
export const ipv4Settings = { tag: 'IPv4', protocol: 'freedom', settings: { domainStrategy: 'UseIPv4' } };
|
||||
export const ipv4Settings = {
|
||||
tag: 'IPv4',
|
||||
protocol: 'freedom',
|
||||
settings: { domainStrategy: 'UseIPv4' },
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import { blockedSettings, directSettings } from './constants';
|
||||
|
||||
export function ruleGetter(t: XraySettingsValue | null, outboundTag: string, property: string): string[] {
|
||||
export function ruleGetter(
|
||||
t: XraySettingsValue | null,
|
||||
outboundTag: string,
|
||||
property: string,
|
||||
): string[] {
|
||||
if (!t?.routing?.rules) return [];
|
||||
const out: string[] = [];
|
||||
for (const rule of t.routing.rules) {
|
||||
@@ -18,7 +22,12 @@ export function ruleGetter(t: XraySettingsValue | null, outboundTag: string, pro
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ruleSetter(t: XraySettingsValue, outboundTag: string, property: string, data: string[]): void {
|
||||
export function ruleSetter(
|
||||
t: XraySettingsValue,
|
||||
outboundTag: string,
|
||||
property: string,
|
||||
data: string[],
|
||||
): void {
|
||||
if (!t.routing) return;
|
||||
if (!Array.isArray(t.routing.rules)) t.routing.rules = [];
|
||||
const current = ruleGetter(t, outboundTag, property);
|
||||
|
||||
@@ -93,7 +93,10 @@ export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresets
|
||||
<div key={preset.name} className="preset-row">
|
||||
<Space size="small" align="center">
|
||||
{preset.tags.map((tag) => (
|
||||
<Tag key={tag} color={tag === 'Family' ? 'purple' : tag === 'UDP' ? 'orange' : 'green'}>
|
||||
<Tag
|
||||
key={tag}
|
||||
color={tag === 'Family' ? 'purple' : tag === 'UDP' ? 'orange' : 'green'}
|
||||
>
|
||||
{tagLabel(tag, t)}
|
||||
</Tag>
|
||||
))}
|
||||
|
||||
@@ -92,20 +92,20 @@ function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
|
||||
}
|
||||
|
||||
function valuesToWire(values: DnsServerForm): DnsServerValue {
|
||||
const isPlain
|
||||
= values.domains.length === 0
|
||||
&& values.expectedIPs.length === 0
|
||||
&& values.unexpectedIPs.length === 0
|
||||
&& values.port === 53
|
||||
&& values.queryStrategy === 'UseIP'
|
||||
&& values.skipFallback === false
|
||||
&& values.disableCache === false
|
||||
&& values.finalQuery === false
|
||||
&& !values.tag
|
||||
&& !values.clientIP
|
||||
&& values.serveStale === false
|
||||
&& values.serveExpiredTTL === 0
|
||||
&& values.timeoutMs === 4000;
|
||||
const isPlain =
|
||||
values.domains.length === 0 &&
|
||||
values.expectedIPs.length === 0 &&
|
||||
values.unexpectedIPs.length === 0 &&
|
||||
values.port === 53 &&
|
||||
values.queryStrategy === 'UseIP' &&
|
||||
values.skipFallback === false &&
|
||||
values.disableCache === false &&
|
||||
values.finalQuery === false &&
|
||||
!values.tag &&
|
||||
!values.clientIP &&
|
||||
values.serveStale === false &&
|
||||
values.serveExpiredTTL === 0 &&
|
||||
values.timeoutMs === 4000;
|
||||
if (isPlain) return values.address;
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
@@ -160,11 +160,7 @@ export default function DnsServerModal({
|
||||
onCancel={onClose}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 8 } }}
|
||||
wrapperCol={{ md: { span: 14 } }}
|
||||
>
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
<FormField
|
||||
label={t('pages.inbounds.address')}
|
||||
name="address"
|
||||
@@ -202,13 +198,27 @@ export default function DnsServerModal({
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
|
||||
<Form.Item label={t('pages.xray.dns.domains')}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('domains', [...domains, ''])} />
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => methods.setValue('domains', [...domains, ''])}
|
||||
/>
|
||||
{domains.map((_, i) => (
|
||||
<Space.Compact key={i} block style={{ marginTop: 4 }}>
|
||||
<FormField name={`domains.${i}`} noStyle>
|
||||
<Input />
|
||||
</FormField>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('domains', domains.filter((__, idx) => idx !== i))}>
|
||||
<InputAddon
|
||||
ariaLabel={t('remove')}
|
||||
onClick={() =>
|
||||
methods.setValue(
|
||||
'domains',
|
||||
domains.filter((__, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
@@ -216,13 +226,27 @@ export default function DnsServerModal({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.xray.dns.expectIPs')}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])} />
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])}
|
||||
/>
|
||||
{expectedIPs.map((_, i) => (
|
||||
<Space.Compact key={i} block style={{ marginTop: 4 }}>
|
||||
<FormField name={`expectedIPs.${i}`} noStyle>
|
||||
<Input />
|
||||
</FormField>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('expectedIPs', expectedIPs.filter((__, idx) => idx !== i))}>
|
||||
<InputAddon
|
||||
ariaLabel={t('remove')}
|
||||
onClick={() =>
|
||||
methods.setValue(
|
||||
'expectedIPs',
|
||||
expectedIPs.filter((__, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
@@ -230,13 +254,27 @@ export default function DnsServerModal({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])} />
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])}
|
||||
/>
|
||||
{unexpectedIPs.map((_, i) => (
|
||||
<Space.Compact key={i} block style={{ marginTop: 4 }}>
|
||||
<FormField name={`unexpectedIPs.${i}`} noStyle>
|
||||
<Input />
|
||||
</FormField>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('unexpectedIPs', unexpectedIPs.filter((__, idx) => idx !== i))}>
|
||||
<InputAddon
|
||||
ariaLabel={t('remove')}
|
||||
onClick={() =>
|
||||
methods.setValue(
|
||||
'unexpectedIPs',
|
||||
unexpectedIPs.filter((__, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
@@ -245,13 +283,21 @@ export default function DnsServerModal({
|
||||
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
|
||||
<FormField label={t('pages.xray.dns.skipFallback')} name="skipFallback" valueProp="checked">
|
||||
<FormField
|
||||
label={t('pages.xray.dns.skipFallback')}
|
||||
name="skipFallback"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.finalQuery')} name="finalQuery" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.disableCache')} name="disableCache" valueProp="checked">
|
||||
<FormField
|
||||
label={t('pages.xray.dns.disableCache')}
|
||||
name="disableCache"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.serveStale')} name="serveStale" valueProp="checked">
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
DeleteOutlined,
|
||||
@@ -54,10 +66,12 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
}
|
||||
if (incomingHosts === lastWrittenHostsRef.current) return;
|
||||
lastWrittenHostsRef.current = incomingHosts;
|
||||
setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
|
||||
domain,
|
||||
values: Array.isArray(values) ? [...values] : [String(values)],
|
||||
})));
|
||||
setHostsList(
|
||||
Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
|
||||
domain,
|
||||
values: Array.isArray(values) ? [...values] : [String(values)],
|
||||
})),
|
||||
);
|
||||
}, [dnsEnabled, incomingHosts, sourceHosts]);
|
||||
|
||||
const mutate = useCallback(
|
||||
@@ -176,9 +190,10 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
okText: t('delete'),
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => mutate((tt) => {
|
||||
if (tt.dns) (tt.dns as DnsConfig).servers = [];
|
||||
}),
|
||||
onOk: () =>
|
||||
mutate((tt) => {
|
||||
if (tt.dns) (tt.dns as DnsConfig).servers = [];
|
||||
}),
|
||||
});
|
||||
}
|
||||
function onPresetInstall(servers: string[]) {
|
||||
@@ -284,11 +299,31 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
/>
|
||||
{(
|
||||
[
|
||||
['disableCache', 'pages.xray.dns.disableCache', 'pages.xray.dns.disableCacheDesc'],
|
||||
['disableFallback', 'pages.xray.dns.disableFallback', 'pages.xray.dns.disableFallbackDesc'],
|
||||
['disableFallbackIfMatch', 'pages.xray.dns.disableFallbackIfMatch', 'pages.xray.dns.disableFallbackIfMatchDesc'],
|
||||
['enableParallelQuery', 'pages.xray.dns.enableParallelQuery', 'pages.xray.dns.enableParallelQueryDesc'],
|
||||
['useSystemHosts', 'pages.xray.dns.useSystemHosts', 'pages.xray.dns.useSystemHostsDesc'],
|
||||
[
|
||||
'disableCache',
|
||||
'pages.xray.dns.disableCache',
|
||||
'pages.xray.dns.disableCacheDesc',
|
||||
],
|
||||
[
|
||||
'disableFallback',
|
||||
'pages.xray.dns.disableFallback',
|
||||
'pages.xray.dns.disableFallbackDesc',
|
||||
],
|
||||
[
|
||||
'disableFallbackIfMatch',
|
||||
'pages.xray.dns.disableFallbackIfMatch',
|
||||
'pages.xray.dns.disableFallbackIfMatchDesc',
|
||||
],
|
||||
[
|
||||
'enableParallelQuery',
|
||||
'pages.xray.dns.enableParallelQuery',
|
||||
'pages.xray.dns.enableParallelQueryDesc',
|
||||
],
|
||||
[
|
||||
'useSystemHosts',
|
||||
'pages.xray.dns.useSystemHosts',
|
||||
'pages.xray.dns.useSystemHostsDesc',
|
||||
],
|
||||
['serveStale', 'pages.xray.dns.serveStale', 'pages.xray.dns.serveStaleDesc'],
|
||||
] as const
|
||||
).map(([field, titleKey, descKey]) => (
|
||||
@@ -330,111 +365,129 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
out.push({
|
||||
key: 'hosts',
|
||||
label: catTabLabel(<ProfileOutlined />, t('pages.xray.dns.hosts'), isMobile),
|
||||
children: hostsList.length === 0 ? (
|
||||
<Empty description={t('pages.xray.dns.hostsEmpty')}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}>
|
||||
{t('pages.xray.dns.hostsAdd')}
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}>
|
||||
{t('pages.xray.dns.hostsAdd')}
|
||||
</Button>
|
||||
{hostsList.map((row, idx) => (
|
||||
<div key={`h${idx}`} className="hosts-row">
|
||||
<Input
|
||||
value={row.domain}
|
||||
aria-label={t('pages.xray.dns.hostsDomain')}
|
||||
placeholder={t('pages.xray.dns.hostsDomain')}
|
||||
style={{ flex: '1 1 220px' }}
|
||||
onChange={(e) => {
|
||||
const next = hostsList.map((r, i) => (i === idx ? { ...r, domain: e.target.value } : r));
|
||||
syncHosts(next);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={row.values}
|
||||
aria-label={t('pages.xray.dns.hostsValues')}
|
||||
placeholder={t('pages.xray.dns.hostsValues')}
|
||||
style={{ flex: '2 1 320px' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
onChange={(values) => {
|
||||
const next = hostsList.map((r, i) => (i === idx ? { ...r, values } : r));
|
||||
syncHosts(next);
|
||||
}}
|
||||
/>
|
||||
<Button danger aria-label={t('delete')} icon={<DeleteOutlined />} onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))} />
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
children:
|
||||
hostsList.length === 0 ? (
|
||||
<Empty description={t('pages.xray.dns.hostsEmpty')}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
|
||||
>
|
||||
{t('pages.xray.dns.hostsAdd')}
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
|
||||
>
|
||||
{t('pages.xray.dns.hostsAdd')}
|
||||
</Button>
|
||||
{hostsList.map((row, idx) => (
|
||||
<div key={`h${idx}`} className="hosts-row">
|
||||
<Input
|
||||
value={row.domain}
|
||||
aria-label={t('pages.xray.dns.hostsDomain')}
|
||||
placeholder={t('pages.xray.dns.hostsDomain')}
|
||||
style={{ flex: '1 1 220px' }}
|
||||
onChange={(e) => {
|
||||
const next = hostsList.map((r, i) =>
|
||||
i === idx ? { ...r, domain: e.target.value } : r,
|
||||
);
|
||||
syncHosts(next);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={row.values}
|
||||
aria-label={t('pages.xray.dns.hostsValues')}
|
||||
placeholder={t('pages.xray.dns.hostsValues')}
|
||||
style={{ flex: '2 1 320px' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
onChange={(values) => {
|
||||
const next = hostsList.map((r, i) => (i === idx ? { ...r, values } : r));
|
||||
syncHosts(next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
aria-label={t('delete')}
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
|
||||
out.push({
|
||||
key: '2',
|
||||
label: catTabLabel(<DatabaseOutlined />, 'DNS', isMobile),
|
||||
children: dnsServers.length === 0 ? (
|
||||
<Empty description={t('emptyDnsDesc')}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
|
||||
{t('pages.xray.dns.add')}
|
||||
</Button>
|
||||
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
|
||||
{t('pages.xray.dns.usePreset')}
|
||||
</Button>
|
||||
children:
|
||||
dnsServers.length === 0 ? (
|
||||
<Empty description={t('emptyDnsDesc')}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
|
||||
{t('pages.xray.dns.add')}
|
||||
</Button>
|
||||
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
|
||||
{t('pages.xray.dns.usePreset')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Empty>
|
||||
) : (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
|
||||
{t('pages.xray.dns.add')}
|
||||
</Button>
|
||||
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
|
||||
{t('pages.xray.dns.usePreset')}
|
||||
</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={clearAllServers}>
|
||||
{t('pages.xray.dns.clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
columns={dnsColumns}
|
||||
dataSource={dnsServers}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</Space>
|
||||
</Empty>
|
||||
) : (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
|
||||
{t('pages.xray.dns.add')}
|
||||
</Button>
|
||||
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
|
||||
{t('pages.xray.dns.usePreset')}
|
||||
</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={clearAllServers}>
|
||||
{t('pages.xray.dns.clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
columns={dnsColumns}
|
||||
dataSource={dnsServers}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
out.push({
|
||||
key: '3',
|
||||
label: catTabLabel(<ExperimentOutlined />, 'Fake DNS', isMobile),
|
||||
children: fakeDnsList.length === 0 ? (
|
||||
<Empty description={t('emptyFakeDnsDesc')}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
|
||||
{t('pages.xray.fakedns.add')}
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
|
||||
{t('pages.xray.fakedns.add')}
|
||||
</Button>
|
||||
<Table
|
||||
columns={fakednsColumns}
|
||||
dataSource={fakeDnsList}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
children:
|
||||
fakeDnsList.length === 0 ? (
|
||||
<Empty description={t('emptyFakeDnsDesc')}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
|
||||
{t('pages.xray.fakedns.add')}
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
|
||||
{t('pages.xray.fakedns.add')}
|
||||
</Button>
|
||||
<Table
|
||||
columns={fakednsColumns}
|
||||
dataSource={fakeDnsList}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,25 @@ export function useDnsServerColumns({
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEditServer(index) },
|
||||
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => deleteServer(index) },
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => openEditServer(index),
|
||||
},
|
||||
{
|
||||
key: 'del',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => deleteServer(index),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
@@ -85,7 +102,14 @@ export function useFakednsColumns({
|
||||
render: (_v, _record, index) => (
|
||||
<Space size={6}>
|
||||
<span className="row-index">{index + 1}</span>
|
||||
<Button aria-label={t('delete')} shape="circle" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteFakedns(index)} />
|
||||
<Button
|
||||
aria-label={t('delete')}
|
||||
shape="circle"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => deleteFakedns(index)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import CountryPill from './CountryPill';
|
||||
@@ -84,9 +88,23 @@ export default function OutboundCardList({
|
||||
<span>{t('pages.xray.outbound.egress')}:</span>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{isEgressVisible ? (
|
||||
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setCardEgressVisible(rowKey, false)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))} />
|
||||
<EyeOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setCardEgressVisible(rowKey, false)}
|
||||
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))}
|
||||
/>
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setCardEgressVisible(rowKey, true)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))} />
|
||||
<EyeInvisibleOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setCardEgressVisible(rowKey, true)}
|
||||
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
{egress.country && (
|
||||
@@ -97,7 +115,13 @@ export default function OutboundCardList({
|
||||
<Tooltip key={addr.label} title={addr.value}>
|
||||
<div className="card-egress-row">
|
||||
<span className="egress-family">{addr.label}:</span>
|
||||
<span className={isEgressVisible ? 'address-visible egress-ip' : 'address-hidden egress-ip'}>{addr.value}</span>
|
||||
<span
|
||||
className={
|
||||
isEgressVisible ? 'address-visible egress-ip' : 'address-hidden egress-ip'
|
||||
}
|
||||
>
|
||||
{addr.value}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -124,10 +148,14 @@ export default function OutboundCardList({
|
||||
<span className="tag-name">{record.tag}</span>
|
||||
</Tooltip>
|
||||
<Tag color="green">{record.protocol}</Tag>
|
||||
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
|
||||
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
|
||||
record.protocol as never,
|
||||
) && (
|
||||
<>
|
||||
<Tag>{record.streamSettings?.network}</Tag>
|
||||
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
|
||||
{showSecurity(record.streamSettings?.security) && (
|
||||
<Tag color="purple">{record.streamSettings?.security}</Tag>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -136,11 +164,46 @@ export default function OutboundCardList({
|
||||
menu={{
|
||||
items: [
|
||||
...(index > 0
|
||||
? [{ key: 'top', label: <><VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}</>, onClick: () => setFirst(index) }]
|
||||
? [
|
||||
{
|
||||
key: 'top',
|
||||
label: (
|
||||
<>
|
||||
<VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}
|
||||
</>
|
||||
),
|
||||
onClick: () => setFirst(index),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
|
||||
{ key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(record.tag || '') },
|
||||
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => openEdit(index),
|
||||
},
|
||||
{
|
||||
key: 'reset',
|
||||
label: (
|
||||
<>
|
||||
<RetweetOutlined /> {t('pages.inbounds.resetTraffic')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onResetTraffic(record.tag || ''),
|
||||
},
|
||||
{
|
||||
key: 'del',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => confirmDelete(index),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
@@ -158,9 +221,13 @@ export default function OutboundCardList({
|
||||
)}
|
||||
{renderEgress(record.key, String(record.key))}
|
||||
<div className="card-foot">
|
||||
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}</span>
|
||||
<span className="traffic-up">
|
||||
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}
|
||||
</span>
|
||||
<span className="traffic-sep" />
|
||||
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
|
||||
<span className="traffic-down">
|
||||
↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}
|
||||
</span>
|
||||
<span className="card-test">
|
||||
{testResult(outboundTestStates, record.key) ? (
|
||||
<TestResultPopover result={testResult(outboundTestStates, record.key)!} />
|
||||
|
||||
@@ -1,31 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { Form, Input, InputNumber, Modal, Radio, Select, Space, Tabs, message } from 'antd';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { JsonEditor } from '@/components/form';
|
||||
import { Wireguard } from '@/utils';
|
||||
import {
|
||||
formValuesToWirePayload,
|
||||
rawOutboundToFormValues,
|
||||
} from '@/lib/xray/outbound-form-adapter';
|
||||
import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
|
||||
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
|
||||
import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
|
||||
import {
|
||||
OutboundFormBaseSchema,
|
||||
type OutboundFormValues,
|
||||
} from '@/schemas/forms/outbound-form';
|
||||
import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
import {
|
||||
canEnableReality,
|
||||
canEnableStream,
|
||||
@@ -110,11 +94,15 @@ export default function OutboundFormModal({
|
||||
|
||||
const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string;
|
||||
const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string;
|
||||
const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ?? '') as string;
|
||||
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ?? 'none') as string;
|
||||
const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ??
|
||||
'') as string;
|
||||
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ??
|
||||
'none') as string;
|
||||
const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
|
||||
const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
|
||||
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as string | undefined;
|
||||
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
const streamAllowed = canEnableStream({ protocol });
|
||||
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
|
||||
@@ -147,9 +135,7 @@ export default function OutboundFormModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const initial = outboundProp
|
||||
? rawOutboundToFormValues(outboundProp)
|
||||
: buildAddModeValues();
|
||||
const initial = outboundProp ? rawOutboundToFormValues(outboundProp) : buildAddModeValues();
|
||||
methods.reset(initial);
|
||||
setActiveKey('1');
|
||||
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
|
||||
@@ -168,7 +154,10 @@ export default function OutboundFormModal({
|
||||
return;
|
||||
}
|
||||
if (network) return;
|
||||
methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
|
||||
methods.setValue('streamSettings', {
|
||||
...newStreamSlice('tcp'),
|
||||
security: 'none',
|
||||
} as StreamValue);
|
||||
}, [streamAllowed, network, protocol, methods]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -206,7 +195,10 @@ export default function OutboundFormModal({
|
||||
if (nextProtocol === 'hysteria') {
|
||||
methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
|
||||
} else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
|
||||
methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
|
||||
methods.setValue('streamSettings', {
|
||||
...newStreamSlice('tcp'),
|
||||
security: 'none',
|
||||
} as StreamValue);
|
||||
}
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
@@ -336,8 +328,9 @@ export default function OutboundFormModal({
|
||||
messageApi.error(t('pages.xray.balancer.reservedPrefix'));
|
||||
return;
|
||||
}
|
||||
const isDuplicateTag = (existingTags || []).includes(tagValue)
|
||||
&& !(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
|
||||
const isDuplicateTag =
|
||||
(existingTags || []).includes(tagValue) &&
|
||||
!(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
|
||||
if (isDuplicateTag) {
|
||||
messageApi.error('Tag already used by another outbound');
|
||||
return;
|
||||
@@ -389,14 +382,23 @@ export default function OutboundFormModal({
|
||||
rules={{ required: 'pages.xray.outboundForm.tagRequired' }}
|
||||
render={({ field, fieldState }) => {
|
||||
const errorMessage = fieldState.error?.message
|
||||
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
|
||||
? t(fieldState.error.message, {
|
||||
defaultValue: fieldState.error.message,
|
||||
})
|
||||
: '';
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('pages.xray.outbound.tag')}
|
||||
required
|
||||
validateStatus={errorMessage ? 'error' : duplicateTag ? 'warning' : undefined}
|
||||
help={errorMessage || (duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined)}
|
||||
validateStatus={
|
||||
errorMessage ? 'error' : duplicateTag ? 'warning' : undefined
|
||||
}
|
||||
help={
|
||||
errorMessage ||
|
||||
(duplicateTag
|
||||
? t('pages.xray.outboundForm.tagDuplicate')
|
||||
: undefined)
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={field.value}
|
||||
@@ -496,7 +498,10 @@ export default function OutboundFormModal({
|
||||
xtls-rprx-vision flow, on TCP+(tls|reality). */}
|
||||
{tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
|
||||
<>
|
||||
<FormField label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.visionTestpre')}
|
||||
name={['settings', 'testpre']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
|
||||
@@ -518,7 +523,9 @@ export default function OutboundFormModal({
|
||||
buttonStyle="solid"
|
||||
onChange={(e) => onSecurityChange(e.target.value as string)}
|
||||
>
|
||||
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
|
||||
{network !== 'hysteria' && (
|
||||
<Radio.Button value="none">{t('none')}</Radio.Button>
|
||||
)}
|
||||
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
|
||||
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
|
||||
</Radio.Group>
|
||||
@@ -529,7 +536,9 @@ export default function OutboundFormModal({
|
||||
|
||||
{security === 'reality' && realityAllowed && <RealityForm />}
|
||||
|
||||
{((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
|
||||
{((streamAllowed && network) ||
|
||||
!streamAllowed ||
|
||||
protocol === 'wireguard') && (
|
||||
<SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
|
||||
)}
|
||||
|
||||
@@ -555,7 +564,11 @@ export default function OutboundFormModal({
|
||||
key: '2',
|
||||
label: 'JSON',
|
||||
children: (
|
||||
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
|
||||
<Space
|
||||
orientation="vertical"
|
||||
size={10}
|
||||
style={{ width: '100%', marginTop: 10 }}
|
||||
>
|
||||
<Input.Search
|
||||
value={linkInput}
|
||||
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
|
||||
|
||||
@@ -47,7 +47,13 @@ import { propagateOutboundTagRename } from '../basics/helpers';
|
||||
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
|
||||
import DeletionImpactList from '../DeletionImpactList';
|
||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import type {
|
||||
XraySettingsValue,
|
||||
SetTemplate,
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import './OutboundsTab.css';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
@@ -124,14 +130,25 @@ export default function OutboundsTab({
|
||||
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
|
||||
const [subs, setSubs] = useState<OutboundSub[]>([]);
|
||||
const [subsLoading, setSubsLoading] = useState(false);
|
||||
const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false });
|
||||
const [newSub, setNewSub] = useState({
|
||||
remark: '',
|
||||
url: '',
|
||||
tagPrefix: '',
|
||||
updateInterval: 600,
|
||||
enabled: true,
|
||||
allowPrivate: false,
|
||||
allowInsecure: false,
|
||||
prepend: false,
|
||||
});
|
||||
const [editingSubId, setEditingSubId] = useState<number | null>(null);
|
||||
const [savingSub, setSavingSub] = useState(false);
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null);
|
||||
const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Convenience: expose hours/minutes for the interval input
|
||||
const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
|
||||
@@ -184,7 +201,9 @@ export default function OutboundsTab({
|
||||
function openAdd() {
|
||||
setEditingOutbound(null);
|
||||
setEditingIndex(null);
|
||||
setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
|
||||
setExistingTags(
|
||||
(templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg),
|
||||
);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
@@ -281,7 +300,11 @@ export default function OutboundsTab({
|
||||
return;
|
||||
}
|
||||
const obj = parsed as { outbounds?: unknown };
|
||||
const list = Array.isArray(parsed) ? parsed : Array.isArray(obj?.outbounds) ? obj.outbounds : null;
|
||||
const list = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray(obj?.outbounds)
|
||||
? obj.outbounds
|
||||
: null;
|
||||
if (!list) {
|
||||
messageApi.error(t('pages.xray.importInvalidJson'));
|
||||
return;
|
||||
@@ -305,7 +328,16 @@ export default function OutboundsTab({
|
||||
setSubsLoading(false);
|
||||
}
|
||||
}
|
||||
function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; allowInsecure?: boolean; prepend?: boolean }) {
|
||||
function subBody(src: {
|
||||
remark?: string;
|
||||
url?: string;
|
||||
tagPrefix?: string;
|
||||
updateInterval?: number;
|
||||
enabled?: boolean;
|
||||
allowPrivate?: boolean;
|
||||
allowInsecure?: boolean;
|
||||
prepend?: boolean;
|
||||
}) {
|
||||
return {
|
||||
remark: src.remark ?? '',
|
||||
url: src.url ?? '',
|
||||
@@ -318,7 +350,16 @@ export default function OutboundsTab({
|
||||
};
|
||||
}
|
||||
function resetSubForm() {
|
||||
setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false });
|
||||
setNewSub({
|
||||
remark: '',
|
||||
url: '',
|
||||
tagPrefix: '',
|
||||
updateInterval: 600,
|
||||
enabled: true,
|
||||
allowPrivate: false,
|
||||
allowInsecure: false,
|
||||
prepend: false,
|
||||
});
|
||||
setEditingSubId(null);
|
||||
setPreviewData(null);
|
||||
}
|
||||
@@ -343,12 +384,19 @@ export default function OutboundsTab({
|
||||
}
|
||||
setSavingSub(true);
|
||||
try {
|
||||
const url = editingSubId != null
|
||||
? `/panel/api/xray/outbound-subs/${editingSubId}`
|
||||
: '/panel/api/xray/outbound-subs';
|
||||
const url =
|
||||
editingSubId != null
|
||||
? `/panel/api/xray/outbound-subs/${editingSubId}`
|
||||
: '/panel/api/xray/outbound-subs';
|
||||
const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
|
||||
if (r?.success) {
|
||||
messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded'));
|
||||
messageApi.success(
|
||||
t(
|
||||
editingSubId != null
|
||||
? 'pages.xray.outboundSub.toastUpdated'
|
||||
: 'pages.xray.outboundSub.toastAdded',
|
||||
),
|
||||
);
|
||||
const createdId = editingSubId == null ? r.obj?.id : undefined;
|
||||
resetSubForm();
|
||||
await loadSubs();
|
||||
@@ -371,7 +419,10 @@ export default function OutboundsTab({
|
||||
setPreviewing(true);
|
||||
setPreviewData(null);
|
||||
try {
|
||||
const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>('/panel/api/xray/outbound-subs/parse', { url: newSub.url, allowPrivate: newSub.allowPrivate });
|
||||
const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>(
|
||||
'/panel/api/xray/outbound-subs/parse',
|
||||
{ url: newSub.url, allowPrivate: newSub.allowPrivate },
|
||||
);
|
||||
if (r?.success && Array.isArray(r.obj)) {
|
||||
setPreviewData(r.obj);
|
||||
if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
|
||||
@@ -387,7 +438,10 @@ export default function OutboundsTab({
|
||||
async function toggleEnabled(sub: OutboundSub) {
|
||||
setBusyId(sub.id);
|
||||
try {
|
||||
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${sub.id}`, subBody({ ...sub, enabled: !sub.enabled }));
|
||||
const r = await HttpUtil.post(
|
||||
`/panel/api/xray/outbound-subs/${sub.id}`,
|
||||
subBody({ ...sub, enabled: !sub.enabled }),
|
||||
);
|
||||
if (r?.success) {
|
||||
await loadSubs();
|
||||
onRefreshXrayData?.();
|
||||
@@ -436,7 +490,11 @@ export default function OutboundsTab({
|
||||
setRefreshingAll(true);
|
||||
try {
|
||||
for (const s of subs) {
|
||||
try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ }
|
||||
try {
|
||||
await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`);
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
|
||||
await loadSubs();
|
||||
@@ -493,8 +551,19 @@ export default function OutboundsTab({
|
||||
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
||||
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
||||
{ type: 'divider' },
|
||||
{ key: 'import', icon: <ImportOutlined />, label: t('pages.xray.importOutbounds'), onClick: () => setImportOpen(true) },
|
||||
{ key: 'export', icon: <ExportOutlined />, label: t('pages.xray.exportOutbounds'), disabled: outbounds.length === 0, onClick: exportOutbounds },
|
||||
{
|
||||
key: 'import',
|
||||
icon: <ImportOutlined />,
|
||||
label: t('pages.xray.importOutbounds'),
|
||||
onClick: () => setImportOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
icon: <ExportOutlined />,
|
||||
label: t('pages.xray.exportOutbounds'),
|
||||
disabled: outbounds.length === 0,
|
||||
onClick: exportOutbounds,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
@@ -505,13 +574,23 @@ export default function OutboundsTab({
|
||||
<Col xs={24} sm={12} className="toolbar-right">
|
||||
<Space size="small" wrap>
|
||||
<Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
|
||||
<Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
|
||||
<Radio.Group
|
||||
value={testMode}
|
||||
onChange={(e) => setTestMode(e.target.value)}
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="tcp">TCP</Radio.Button>
|
||||
<Radio.Button value="http">HTTP</Radio.Button>
|
||||
<Radio.Button value="real">{t('pages.xray.outbound.modeRealDelay')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Tooltip>
|
||||
<Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={testingAll}
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => onTestAll(testMode)}
|
||||
>
|
||||
{!isMobile && t('pages.xray.outbound.testAll')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
@@ -614,13 +693,25 @@ export default function OutboundsTab({
|
||||
)}
|
||||
<Form layout="vertical" size="small">
|
||||
<Form.Item label={t('pages.xray.outboundSub.remark')}>
|
||||
<Input value={newSub.remark} onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} />
|
||||
<Input
|
||||
value={newSub.remark}
|
||||
onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })}
|
||||
placeholder={t('pages.xray.outboundSub.remarkPlaceholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundSub.url')} required>
|
||||
<Input value={newSub.url} onChange={(e) => setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} />
|
||||
<Input
|
||||
value={newSub.url}
|
||||
onChange={(e) => setNewSub({ ...newSub, url: e.target.value })}
|
||||
placeholder={t('pages.xray.outboundSub.urlPlaceholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
|
||||
<Input value={newSub.tagPrefix} onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} />
|
||||
<Input
|
||||
value={newSub.tagPrefix}
|
||||
onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })}
|
||||
placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundSub.interval')}>
|
||||
<Space>
|
||||
@@ -629,42 +720,61 @@ export default function OutboundsTab({
|
||||
value={intervalHours}
|
||||
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
|
||||
style={{ width: 80 }}
|
||||
/> {t('pages.xray.outboundSub.hours')}
|
||||
/>{' '}
|
||||
{t('pages.xray.outboundSub.hours')}
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={59}
|
||||
value={intervalMinutes}
|
||||
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
|
||||
style={{ width: 80 }}
|
||||
/> {t('pages.xray.outboundSub.minutes')}
|
||||
/>{' '}
|
||||
{t('pages.xray.outboundSub.minutes')}
|
||||
</Space>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
{t('pages.xray.outboundSub.intervalHint')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundSub.enabled')}>
|
||||
<Switch checked={newSub.enabled} onChange={(v) => setNewSub({ ...newSub, enabled: v })} />
|
||||
<Switch
|
||||
checked={newSub.enabled}
|
||||
onChange={(v) => setNewSub({ ...newSub, enabled: v })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
|
||||
<Switch checked={newSub.allowPrivate} onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })} />
|
||||
<Switch
|
||||
checked={newSub.allowPrivate}
|
||||
onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
{t('pages.xray.outboundSub.allowPrivateHint')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.hosts.fields.allowInsecure')}>
|
||||
<Switch checked={newSub.allowInsecure} onChange={(v) => setNewSub({ ...newSub, allowInsecure: v })} />
|
||||
<Switch
|
||||
checked={newSub.allowInsecure}
|
||||
onChange={(v) => setNewSub({ ...newSub, allowInsecure: v })}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
{t('pages.hosts.hints.allowInsecure')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundSub.prepend')}>
|
||||
<Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
|
||||
<Switch
|
||||
checked={newSub.prepend}
|
||||
onChange={(v) => setNewSub({ ...newSub, prepend: v })}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
{t('pages.xray.outboundSub.prependHint')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={saveSub} loading={savingSub} icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={saveSub}
|
||||
loading={savingSub}
|
||||
icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}
|
||||
>
|
||||
{editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
|
||||
</Button>
|
||||
<Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
|
||||
@@ -674,10 +784,23 @@ export default function OutboundsTab({
|
||||
</Space>
|
||||
{previewData && previewData.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>{previewData.length} · {t('pages.xray.Outbounds')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 120, overflow: 'auto' }}>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
|
||||
{previewData.length} · {t('pages.xray.Outbounds')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{previewData.map((o, i) => (
|
||||
<Tag key={i}>{o?.tag || '—'}{o?.protocol ? ` · ${o.protocol}` : ''}</Tag>
|
||||
<Tag key={i}>
|
||||
{o?.tag || '—'}
|
||||
{o?.protocol ? ` · ${o.protocol}` : ''}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -686,11 +809,31 @@ export default function OutboundsTab({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
marginBottom: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{t('pages.xray.outboundSub.active')}
|
||||
<Button aria-label={t('refresh')} size="small" icon={<ReloadOutlined />} onClick={loadSubs} loading={subsLoading} />
|
||||
<Button
|
||||
aria-label={t('refresh')}
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadSubs}
|
||||
loading={subsLoading}
|
||||
/>
|
||||
{subs.length > 0 && (
|
||||
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={refreshAllSubs}
|
||||
loading={refreshingAll}
|
||||
>
|
||||
{t('pages.xray.outboundSub.refreshAll')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -711,8 +854,22 @@ export default function OutboundsTab({
|
||||
width: 56,
|
||||
render: (_: unknown, r: OutboundSub, index: number) => (
|
||||
<Space size={0}>
|
||||
<Button aria-label={t('pages.inbounds.form.moveUp')} type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} />
|
||||
<Button aria-label={t('pages.inbounds.form.moveDown')} type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
|
||||
<Button
|
||||
aria-label={t('pages.inbounds.form.moveUp')}
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0 || busyId === r.id}
|
||||
onClick={() => moveSub(r.id, 'up')}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('pages.inbounds.form.moveDown')}
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={index === subs.length - 1 || busyId === r.id}
|
||||
onClick={() => moveSub(r.id, 'down')}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -722,35 +879,86 @@ export default function OutboundsTab({
|
||||
render: (_: unknown, r: OutboundSub) => (
|
||||
<div>
|
||||
<div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
|
||||
{r.tagPrefix && <div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>}
|
||||
{r.tagPrefix && (
|
||||
<div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: t('pages.xray.Outbounds'), dataIndex: 'outboundCount', key: 'outboundCount', align: 'center', render: (v) => v ?? 0 },
|
||||
{
|
||||
title: t('pages.xray.Outbounds'),
|
||||
dataIndex: 'outboundCount',
|
||||
key: 'outboundCount',
|
||||
align: 'center',
|
||||
render: (v) => v ?? 0,
|
||||
},
|
||||
{
|
||||
title: t('status'),
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
render: (_: unknown, r: OutboundSub) => (r.lastError
|
||||
? <Tooltip title={r.lastError}><WarningOutlined style={{ color: '#e04141' }} /></Tooltip>
|
||||
: <Tooltip title={t('pages.xray.outboundSub.statusOk')}><CheckCircleOutlined style={{ color: '#008771' }} /></Tooltip>),
|
||||
render: (_: unknown, r: OutboundSub) =>
|
||||
r.lastError ? (
|
||||
<Tooltip title={r.lastError}>
|
||||
<WarningOutlined style={{ color: '#e04141' }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={t('pages.xray.outboundSub.statusOk')}>
|
||||
<CheckCircleOutlined style={{ color: '#008771' }} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.xray.outboundSub.colLastFetch'),
|
||||
dataIndex: 'lastUpdated',
|
||||
key: 'lastUpdated',
|
||||
render: (v: number) =>
|
||||
v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never'),
|
||||
},
|
||||
{ title: t('pages.xray.outboundSub.colLastFetch'), dataIndex: 'lastUpdated', key: 'lastUpdated', render: (v: number) => v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never') },
|
||||
{
|
||||
title: t('pages.xray.outboundSub.colEnabled'),
|
||||
key: 'enabled',
|
||||
align: 'center',
|
||||
render: (_: unknown, r: OutboundSub) => <Switch size="small" checked={!!r.enabled} loading={busyId === r.id} onChange={() => toggleEnabled(r)} />,
|
||||
render: (_: unknown, r: OutboundSub) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={!!r.enabled}
|
||||
loading={busyId === r.id}
|
||||
onChange={() => toggleEnabled(r)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
render: (_: unknown, r: OutboundSub) => (
|
||||
<Space>
|
||||
<Button aria-label={t('edit')} size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} />
|
||||
<Button aria-label={t('pages.xray.outboundSub.refreshNow')} size="small" icon={<ReloadOutlined />} loading={refreshingId === r.id} onClick={() => refreshOne(r.id)} title={t('pages.xray.outboundSub.refreshNow')} />
|
||||
<Popconfirm title={t('pages.xray.outboundSub.deleteConfirm')} okText={t('delete')} cancelText={t('cancel')} onConfirm={() => deleteOne(r.id)}>
|
||||
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
|
||||
<Button
|
||||
aria-label={t('edit')}
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditSub(r)}
|
||||
title={t('edit')}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('pages.xray.outboundSub.refreshNow')}
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshingId === r.id}
|
||||
onClick={() => refreshOne(r.id)}
|
||||
title={t('pages.xray.outboundSub.refreshNow')}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('pages.xray.outboundSub.deleteConfirm')}
|
||||
okText={t('delete')}
|
||||
cancelText={t('cancel')}
|
||||
onConfirm={() => deleteOne(r.id)}
|
||||
>
|
||||
<Button
|
||||
aria-label={t('delete')}
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -6,7 +6,11 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
@@ -44,7 +48,8 @@ export default function SubscriptionOutbounds({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const rows = useMemo<OutboundRow[]>(
|
||||
() => (subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
|
||||
() =>
|
||||
(subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
|
||||
[subscriptionOutbounds],
|
||||
);
|
||||
|
||||
@@ -57,10 +62,14 @@ export default function SubscriptionOutbounds({
|
||||
</Tooltip>
|
||||
<div className="protocol-line">
|
||||
<Tag color="green">{record.protocol}</Tag>
|
||||
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
|
||||
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
|
||||
record.protocol as never,
|
||||
) && (
|
||||
<>
|
||||
<Tag>{record.streamSettings?.network}</Tag>
|
||||
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
|
||||
{showSecurity(record.streamSettings?.security) && (
|
||||
<Tag color="purple">{record.streamSettings?.security}</Tag>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -98,7 +107,12 @@ export default function SubscriptionOutbounds({
|
||||
const latencyCell = (record: OutboundRow) => {
|
||||
const key = record.tag || '';
|
||||
const r = testResult(subscriptionTestStates, key);
|
||||
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||
if (!r)
|
||||
return isTesting(subscriptionTestStates, key) ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
<span className="empty">—</span>
|
||||
);
|
||||
return <TestResultPopover result={r} />;
|
||||
};
|
||||
|
||||
@@ -122,7 +136,9 @@ export default function SubscriptionOutbounds({
|
||||
|
||||
const header = (
|
||||
<div className="subscription-outbounds-head">
|
||||
<div className="subscription-outbounds-title">{t('pages.xray.outboundSub.fromSubsTitle')}</div>
|
||||
<div className="subscription-outbounds-title">
|
||||
{t('pages.xray.outboundSub.fromSubsTitle')}
|
||||
</div>
|
||||
<div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -159,17 +175,51 @@ export default function SubscriptionOutbounds({
|
||||
width: 60,
|
||||
render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
|
||||
},
|
||||
{ title: t('pages.xray.outbound.tag'), key: 'identity', align: 'left', render: (_v, record) => identityCell(record) },
|
||||
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', render: (_v, record) => addressCell(record) },
|
||||
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200, render: (_v, record) => trafficCell(record) },
|
||||
{ title: t('pages.nodes.latency'), key: 'testResult', align: 'left', width: 140, render: (_v, record) => latencyCell(record) },
|
||||
{ title: t('check'), key: 'test', align: 'center', width: 80, render: (_v, record) => testButton(record) },
|
||||
{
|
||||
title: t('pages.xray.outbound.tag'),
|
||||
key: 'identity',
|
||||
align: 'left',
|
||||
render: (_v, record) => identityCell(record),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.address'),
|
||||
key: 'address',
|
||||
align: 'left',
|
||||
render: (_v, record) => addressCell(record),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.traffic'),
|
||||
key: 'traffic',
|
||||
align: 'left',
|
||||
width: 200,
|
||||
render: (_v, record) => trafficCell(record),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.latency'),
|
||||
key: 'testResult',
|
||||
align: 'left',
|
||||
width: 140,
|
||||
render: (_v, record) => latencyCell(record),
|
||||
},
|
||||
{
|
||||
title: t('check'),
|
||||
key: 'test',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_v, record) => testButton(record),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
|
||||
{header}
|
||||
<Table columns={columns} dataSource={rows} rowKey={(r) => r.key} pagination={false} size="small" />
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,16 +21,32 @@ export default function TestResultPopover({ result: r, children }: TestResultPop
|
||||
|
||||
const breakdown: Array<{ key: string; label: string; value: string }> = [];
|
||||
if (typeof r.httpStatus === 'number') {
|
||||
breakdown.push({ key: 'status', label: t('pages.xray.outbound.httpStatus'), value: String(r.httpStatus) });
|
||||
breakdown.push({
|
||||
key: 'status',
|
||||
label: t('pages.xray.outbound.httpStatus'),
|
||||
value: String(r.httpStatus),
|
||||
});
|
||||
}
|
||||
if (typeof r.connectMs === 'number') {
|
||||
breakdown.push({ key: 'connect', label: t('pages.xray.outbound.breakdownConnect'), value: `${r.connectMs} ms` });
|
||||
breakdown.push({
|
||||
key: 'connect',
|
||||
label: t('pages.xray.outbound.breakdownConnect'),
|
||||
value: `${r.connectMs} ms`,
|
||||
});
|
||||
}
|
||||
if (typeof r.tlsMs === 'number') {
|
||||
breakdown.push({ key: 'tls', label: t('pages.xray.outbound.breakdownTls'), value: `${r.tlsMs} ms` });
|
||||
breakdown.push({
|
||||
key: 'tls',
|
||||
label: t('pages.xray.outbound.breakdownTls'),
|
||||
value: `${r.tlsMs} ms`,
|
||||
});
|
||||
}
|
||||
if (typeof r.ttfbMs === 'number') {
|
||||
breakdown.push({ key: 'ttfb', label: t('pages.xray.outbound.breakdownTtfb'), value: `${r.ttfbMs} ms` });
|
||||
breakdown.push({
|
||||
key: 'ttfb',
|
||||
label: t('pages.xray.outbound.breakdownTtfb'),
|
||||
value: `${r.ttfbMs} ms`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -47,7 +63,9 @@ export default function TestResultPopover({ result: r, children }: TestResultPop
|
||||
<div key={ep.address} className="endpoint-row">
|
||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
||||
<span className="ep-addr">{ep.address}</span>
|
||||
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
||||
<span className="ep-meta">
|
||||
{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{breakdown.map((row) => (
|
||||
|
||||
@@ -28,7 +28,14 @@ export const TARGET_STRATEGY_OPTIONS = OutboundDomainStrategySchema.options.map(
|
||||
|
||||
// canEnableMux mirrors the adapter's helper but lives here so the modal
|
||||
// can show/hide the Mux section without going through the adapter.
|
||||
export const MUX_PROTOCOLS = new Set<string>(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
|
||||
export const MUX_PROTOCOLS = new Set<string>([
|
||||
'vmess',
|
||||
'vless',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'http',
|
||||
'socks',
|
||||
]);
|
||||
|
||||
export const NETWORK_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'tcp', label: 'RAW' },
|
||||
@@ -48,5 +55,11 @@ export const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' };
|
||||
// protocol section. Wireguard has an address but no port. DNS/freedom/
|
||||
// blackhole/loopback have no connect target.
|
||||
export const SERVER_PROTOCOLS = new Set<string>([
|
||||
'vmess', 'vless', 'trojan', 'shadowsocks', 'socks', 'http', 'hysteria',
|
||||
'vmess',
|
||||
'vless',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'socks',
|
||||
'http',
|
||||
'hysteria',
|
||||
]);
|
||||
|
||||
@@ -21,8 +21,12 @@ export function newStreamSlice(network: string): Record<string, unknown> {
|
||||
return {
|
||||
network: 'kcp',
|
||||
kcpSettings: {
|
||||
mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
|
||||
cwndMultiplier: 1, maxSendingWindow: 2097152,
|
||||
mtu: 1350,
|
||||
tti: 20,
|
||||
uplinkCapacity: 5,
|
||||
downlinkCapacity: 20,
|
||||
cwndMultiplier: 1,
|
||||
maxSendingWindow: 2097152,
|
||||
},
|
||||
};
|
||||
case 'ws':
|
||||
@@ -44,7 +48,10 @@ export function newStreamSlice(network: string): Record<string, unknown> {
|
||||
return {
|
||||
network: 'xhttp',
|
||||
xhttpSettings: {
|
||||
path: '/', host: '', mode: '', headers: [],
|
||||
path: '/',
|
||||
host: '',
|
||||
mode: '',
|
||||
headers: [],
|
||||
xPaddingBytes: '100-1000',
|
||||
},
|
||||
};
|
||||
@@ -69,8 +76,12 @@ export function hysteriaStreamSlice(): Record<string, unknown> {
|
||||
...newStreamSlice('hysteria'),
|
||||
security: 'tls',
|
||||
tlsSettings: {
|
||||
serverName: '', alpn: ['h3'], fingerprint: '',
|
||||
echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
|
||||
serverName: '',
|
||||
alpn: ['h3'],
|
||||
fingerprint: '',
|
||||
echConfigList: '',
|
||||
verifyPeerCertByName: '',
|
||||
pinnedPeerCertSha256: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -88,8 +99,14 @@ export function applyNetworkChange(
|
||||
if (next === 'hysteria') return hysteriaStreamSlice();
|
||||
const stream = prevStream ?? {};
|
||||
const currentSecurity = (stream.security as string) ?? 'none';
|
||||
const stillTls = canEnableTls({ protocol, streamSettings: { network: next, security: currentSecurity } });
|
||||
const stillReality = canEnableReality({ protocol, streamSettings: { network: next, security: currentSecurity } });
|
||||
const stillTls = canEnableTls({
|
||||
protocol,
|
||||
streamSettings: { network: next, security: currentSecurity },
|
||||
});
|
||||
const stillReality = canEnableReality({
|
||||
protocol,
|
||||
streamSettings: { network: next, security: currentSecurity },
|
||||
});
|
||||
const newSecurity =
|
||||
currentSecurity === 'tls' && !stillTls
|
||||
? 'none'
|
||||
@@ -98,7 +115,8 @@ export function applyNetworkChange(
|
||||
: currentSecurity;
|
||||
const newStream: Record<string, unknown> = { ...newStreamSlice(next), security: newSecurity };
|
||||
if (newSecurity === 'tls' && stream.tlsSettings) newStream.tlsSettings = stream.tlsSettings;
|
||||
else if (newSecurity === 'reality' && stream.realitySettings) newStream.realitySettings = stream.realitySettings;
|
||||
else if (newSecurity === 'reality' && stream.realitySettings)
|
||||
newStream.realitySettings = stream.realitySettings;
|
||||
return newStream;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { TFunction } from 'i18next';
|
||||
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
|
||||
@@ -37,11 +41,14 @@ export function outboundAddresses(o: OutboundRow): string[] {
|
||||
}
|
||||
case Protocols.DNS: {
|
||||
const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || '';
|
||||
const port = (settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
|
||||
const port =
|
||||
(settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
|
||||
return addr || port ? [`${addr}:${port}`] : [];
|
||||
}
|
||||
case Protocols.Wireguard:
|
||||
return (((settings?.peers as Array<{ endpoint?: string }>) || []).map((p) => p.endpoint || '').filter(Boolean));
|
||||
return ((settings?.peers as Array<{ endpoint?: string }>) || [])
|
||||
.map((p) => p.endpoint || '')
|
||||
.filter(Boolean);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -49,7 +56,12 @@ export function outboundAddresses(o: OutboundRow): string[] {
|
||||
|
||||
export function isUntestable(o: OutboundRow): boolean {
|
||||
if (!o) return true;
|
||||
if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
|
||||
if (
|
||||
o.protocol === Protocols.Blackhole ||
|
||||
o.protocol === Protocols.Loopback ||
|
||||
o.tag === 'blocked'
|
||||
)
|
||||
return true;
|
||||
// freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
|
||||
// an HTTP probe would only measure the host's own direct reachability, so
|
||||
// they're untestable in every mode.
|
||||
@@ -69,7 +81,10 @@ export function testModeLabel(mode: string, t: TFunction): string {
|
||||
return mode === 'real' ? t('pages.xray.outbound.modeRealDelay') : mode.toUpperCase();
|
||||
}
|
||||
|
||||
export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
|
||||
export function trafficFor(
|
||||
outboundsTraffic: OutboundTrafficRow[],
|
||||
o: OutboundRow,
|
||||
): { up: number; down: number } {
|
||||
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
|
||||
return { up: tr?.up || 0, down: tr?.down || 0 };
|
||||
}
|
||||
@@ -84,16 +99,24 @@ export function countryName(country?: string, locale?: string): string {
|
||||
const code = (country || '').trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(code)) return '';
|
||||
try {
|
||||
return new Intl.DisplayNames(locale ? [locale] : undefined, { type: 'region' }).of(code) || code;
|
||||
return (
|
||||
new Intl.DisplayNames(locale ? [locale] : undefined, { type: 'region' }).of(code) || code
|
||||
);
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
|
||||
export function isTesting<K extends string | number>(
|
||||
states: Record<K, OutboundTestState>,
|
||||
idx: K,
|
||||
): boolean {
|
||||
return !!states?.[idx]?.testing;
|
||||
}
|
||||
|
||||
export function testResult<K extends string | number>(states: Record<K, OutboundTestState>, idx: K) {
|
||||
export function testResult<K extends string | number>(
|
||||
states: Record<K, OutboundTestState>,
|
||||
idx: K,
|
||||
) {
|
||||
return states?.[idx]?.result || null;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ export default function DnsFields() {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'settings.rules' });
|
||||
return (
|
||||
<>
|
||||
<FormField label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.rewriteNetwork')}
|
||||
name={['settings', 'rewriteNetwork']}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('pages.xray.outboundForm.unchanged')}
|
||||
@@ -23,7 +26,10 @@ export default function DnsFields() {
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.rewriteAddress')}
|
||||
name={['settings', 'rewriteAddress']}
|
||||
>
|
||||
<Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
|
||||
@@ -56,10 +62,11 @@ export default function DnsFields() {
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'rules', index, 'action']}>
|
||||
<Select
|
||||
options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
|
||||
/>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.action')}
|
||||
name={['settings', 'rules', index, 'action']}
|
||||
>
|
||||
<Select options={DNSRuleActions.map((a) => ({ value: a, label: a }))} />
|
||||
</FormField>
|
||||
<FormField label="QType" name={['settings', 'rules', index, 'qType']}>
|
||||
<Input placeholder="1,3,23-24" />
|
||||
|
||||
@@ -32,11 +32,16 @@ export default function FreedomFields() {
|
||||
append: appendFinalRule,
|
||||
remove: removeFinalRule,
|
||||
} = useFieldArray({ control, name: 'settings.finalRules' });
|
||||
const finalRulesValues = (useWatch({ control, name: 'settings.finalRules' }) ?? []) as { action?: string }[];
|
||||
const finalRulesValues = (useWatch({ control, name: 'settings.finalRules' }) ?? []) as {
|
||||
action?: string;
|
||||
}[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}>
|
||||
<FormField
|
||||
label={t('pages.xray.balancer.balancerStrategy')}
|
||||
name={['settings', 'domainStrategy']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: `(${t('none')})` },
|
||||
@@ -50,7 +55,10 @@ export default function FreedomFields() {
|
||||
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.proxyProtocol')}
|
||||
name={['settings', 'proxyProtocol']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 0, label: `(${t('none')})` },
|
||||
@@ -68,11 +76,11 @@ export default function FreedomFields() {
|
||||
'settings.fragment',
|
||||
checked
|
||||
? {
|
||||
packets: 'tlshello',
|
||||
length: '100-200',
|
||||
interval: '10-20',
|
||||
maxSplit: '300-400',
|
||||
}
|
||||
packets: 'tlshello',
|
||||
length: '100-200',
|
||||
interval: '10-20',
|
||||
maxSplit: '300-400',
|
||||
}
|
||||
: { packets: '', length: '', interval: '', maxSplit: '' },
|
||||
);
|
||||
}}
|
||||
@@ -101,13 +109,22 @@ export default function FreedomFields() {
|
||||
placeholder="tlshello or n-m, e.g. 1-3"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.length')}
|
||||
name={['settings', 'fragment', 'length']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.interval')} name={['settings', 'fragment', 'interval']}>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.interval')}
|
||||
name={['settings', 'fragment', 'interval']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.maxSplit')} name={['settings', 'fragment', 'maxSplit']}>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.maxSplit')}
|
||||
name={['settings', 'fragment', 'maxSplit']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
</>
|
||||
@@ -131,7 +148,9 @@ export default function FreedomFields() {
|
||||
className="ml-8"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })}
|
||||
onClick={() =>
|
||||
appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
@@ -152,7 +171,10 @@ export default function FreedomFields() {
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.settings.subFormats.type')} name={['settings', 'noises', index, 'type']}>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.type')}
|
||||
name={['settings', 'noises', index, 'type']}
|
||||
>
|
||||
<Select
|
||||
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
|
||||
value: v,
|
||||
@@ -160,13 +182,22 @@ export default function FreedomFields() {
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.packet')} name={['settings', 'noises', index, 'packet']}>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.packet')}
|
||||
name={['settings', 'noises', index, 'packet']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.delayMs')} name={['settings', 'noises', index, 'delay']}>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.delayMs')}
|
||||
name={['settings', 'noises', index, 'delay']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.applyTo')} name={['settings', 'noises', index, 'applyTo']}>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.applyTo')}
|
||||
name={['settings', 'noises', index, 'applyTo']}
|
||||
>
|
||||
<Select
|
||||
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
|
||||
value: v,
|
||||
@@ -183,7 +214,9 @@ export default function FreedomFields() {
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })}
|
||||
onClick={() =>
|
||||
appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })
|
||||
}
|
||||
/>
|
||||
<span className="ml-8" style={{ opacity: 0.6 }}>
|
||||
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
|
||||
@@ -204,7 +237,10 @@ export default function FreedomFields() {
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'finalRules', index, 'action']}>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.action')}
|
||||
name={['settings', 'finalRules', index, 'action']}
|
||||
>
|
||||
<Select
|
||||
options={['allow', 'block'].map((v) => ({
|
||||
value: v,
|
||||
@@ -212,7 +248,10 @@ export default function FreedomFields() {
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.network')} name={['settings', 'finalRules', index, 'network']}>
|
||||
<FormField
|
||||
label={t('pages.inbounds.network')}
|
||||
name={['settings', 'finalRules', index, 'network']}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="(any)"
|
||||
@@ -222,7 +261,10 @@ export default function FreedomFields() {
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.port')} name={['settings', 'finalRules', index, 'port']}>
|
||||
<FormField
|
||||
label={t('pages.inbounds.port')}
|
||||
name={['settings', 'finalRules', index, 'port']}
|
||||
>
|
||||
<Input placeholder="e.g. 80,443 or 1000-2000" />
|
||||
</FormField>
|
||||
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
|
||||
|
||||
@@ -56,7 +56,10 @@ export default function WireguardFields() {
|
||||
<Form.Item label={t('pages.inbounds.privatekey')}>
|
||||
<Space.Compact block>
|
||||
<FormField name={['settings', 'secretKey']} noStyle>
|
||||
<Input aria-label={t('pages.inbounds.privatekey')} style={{ width: 'calc(100% - 32px)' }} />
|
||||
<Input
|
||||
aria-label={t('pages.inbounds.privatekey')}
|
||||
style={{ width: 'calc(100% - 32px)' }}
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -72,7 +75,10 @@ export default function WireguardFields() {
|
||||
<FormField label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
|
||||
<Input disabled />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}>
|
||||
<FormField
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
name={['settings', 'domainStrategy']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: `(${t('none')})` },
|
||||
@@ -127,10 +133,16 @@ export default function WireguardFields() {
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.xray.wireguard.endpoint')} name={['settings', 'peers', index, 'endpoint']}>
|
||||
<FormField
|
||||
label={t('pages.xray.wireguard.endpoint')}
|
||||
name={['settings', 'peers', index, 'endpoint']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.publicKey')} name={['settings', 'peers', index, 'publicKey']}>
|
||||
<FormField
|
||||
label={t('pages.inbounds.publicKey')}
|
||||
name={['settings', 'peers', index, 'publicKey']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label="PSK" name={['settings', 'peers', index, 'psk']}>
|
||||
@@ -139,7 +151,10 @@ export default function WireguardFields() {
|
||||
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
|
||||
<AllowedIPsList peerIndex={index} />
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.inbounds.info.keepAlive')} name={['settings', 'peers', index, 'keepAlive']}>
|
||||
<FormField
|
||||
label={t('pages.inbounds.info.keepAlive')}
|
||||
name={['settings', 'peers', index, 'keepAlive']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
@@ -9,16 +9,10 @@ export default function RealityForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
label="SNI"
|
||||
name={['streamSettings', 'realitySettings', 'serverName']}
|
||||
>
|
||||
<FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverName']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="uTLS"
|
||||
name={['streamSettings', 'realitySettings', 'fingerprint']}
|
||||
>
|
||||
<FormField label="uTLS" name={['streamSettings', 'realitySettings', 'fingerprint']}>
|
||||
<Select options={UTLS_OPTIONS} />
|
||||
</FormField>
|
||||
<FormField
|
||||
|
||||
@@ -9,32 +9,20 @@ export default function TlsForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
label="SNI"
|
||||
name={['streamSettings', 'tlsSettings', 'serverName']}
|
||||
>
|
||||
<FormField label="SNI" name={['streamSettings', 'tlsSettings', 'serverName']}>
|
||||
<Input placeholder={t('pages.xray.outboundForm.serverNamePlaceholder')} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="uTLS"
|
||||
name={['streamSettings', 'tlsSettings', 'fingerprint']}
|
||||
>
|
||||
<FormField label="uTLS" name={['streamSettings', 'tlsSettings', 'fingerprint']}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('none')}
|
||||
options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="ALPN"
|
||||
name={['streamSettings', 'tlsSettings', 'alpn']}
|
||||
>
|
||||
<FormField label="ALPN" name={['streamSettings', 'tlsSettings', 'alpn']}>
|
||||
<Select mode="multiple" options={ALPN_OPTIONS} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="ECH"
|
||||
name={['streamSettings', 'tlsSettings', 'echConfigList']}
|
||||
>
|
||||
<FormField label="ECH" name={['streamSettings', 'tlsSettings', 'echConfigList']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
|
||||
@@ -8,16 +8,10 @@ export default function HttpUpgradeForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
label={t('host')}
|
||||
name={['streamSettings', 'httpupgradeSettings', 'host']}
|
||||
>
|
||||
<FormField label={t('host')} name={['streamSettings', 'httpupgradeSettings', 'host']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('path')}
|
||||
name={['streamSettings', 'httpupgradeSettings', 'path']}
|
||||
>
|
||||
<FormField label={t('path')} name={['streamSettings', 'httpupgradeSettings', 'path']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
|
||||
@@ -41,10 +41,15 @@ export default function HysteriaForm() {
|
||||
MASQ_DOT,
|
||||
checked
|
||||
? {
|
||||
type: '', dir: '', url: '',
|
||||
rewriteHost: false, insecure: false,
|
||||
content: '', headers: {}, statusCode: 0,
|
||||
}
|
||||
type: '',
|
||||
dir: '',
|
||||
url: '',
|
||||
rewriteHost: false,
|
||||
insecure: false,
|
||||
content: '',
|
||||
headers: {},
|
||||
statusCode: 0,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ export default function KcpForm() {
|
||||
<FormField label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.ttiMs')}
|
||||
name={['streamSettings', 'kcpSettings', 'tti']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
|
||||
@@ -19,11 +19,7 @@ export default function MuxForm({ protocol, network }: MuxFormProps) {
|
||||
if (!isMuxAllowed(protocol, flow, network)) return null;
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.settings.mux')}
|
||||
name={['mux', 'enabled']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<FormField label={t('pages.settings.mux')} name={['mux', 'enabled']} valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
{muxEnabled && (
|
||||
|
||||
@@ -22,20 +22,20 @@ export default function RawForm() {
|
||||
'streamSettings.tcpSettings.header',
|
||||
checked
|
||||
? {
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1',
|
||||
method: 'GET',
|
||||
path: ['/'],
|
||||
headers: {},
|
||||
},
|
||||
response: {
|
||||
version: '1.1',
|
||||
status: '200',
|
||||
reason: 'OK',
|
||||
headers: {},
|
||||
},
|
||||
}
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1',
|
||||
method: 'GET',
|
||||
path: ['/'],
|
||||
headers: {},
|
||||
},
|
||||
response: {
|
||||
version: '1.1',
|
||||
status: '200',
|
||||
reason: 'OK',
|
||||
headers: {},
|
||||
},
|
||||
}
|
||||
: { type: 'none' },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,20 +5,20 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { SockoptCustomField } from '@/lib/xray/forms/fields';
|
||||
import { DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION } from '@/schemas/primitives';
|
||||
import { HappyEyeballsSchema, SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
import {
|
||||
HappyEyeballsSchema,
|
||||
SockoptStreamSettingsSchema,
|
||||
} from '@/schemas/protocols/stream/sockopt';
|
||||
|
||||
import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
|
||||
|
||||
export default function SockoptForm({
|
||||
outboundTags = [],
|
||||
}: {
|
||||
outboundTags?: string[];
|
||||
}) {
|
||||
export default function SockoptForm({ outboundTags = [] }: { outboundTags?: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
const sockopt = useWatch({ control, name: 'streamSettings.sockopt' });
|
||||
const hasSockopt = !!sockopt;
|
||||
const dialerProxy = (useWatch({ control, name: 'streamSettings.sockopt.dialerProxy' }) ?? '') as string;
|
||||
const dialerProxy = (useWatch({ control, name: 'streamSettings.sockopt.dialerProxy' }) ??
|
||||
'') as string;
|
||||
const happyEyeballs = useWatch({ control, name: 'streamSettings.sockopt.happyEyeballs' });
|
||||
const hasHe = happyEyeballs != null;
|
||||
const dialerProxyOptions = Array.from(
|
||||
@@ -107,10 +107,7 @@ export default function SockoptForm({
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="TProxy"
|
||||
name={['streamSettings', 'sockopt', 'tproxy']}
|
||||
>
|
||||
<FormField label="TProxy" name={['streamSettings', 'sockopt', 'tproxy']}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'off', label: 'off' },
|
||||
|
||||
@@ -32,10 +32,14 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
|
||||
const { control, getValues, setValue } = useFormContext();
|
||||
const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
|
||||
const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
|
||||
const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
|
||||
const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as
|
||||
| string
|
||||
| undefined;
|
||||
const table = useWatch({ control, name: `${XH}.sessionIDTable` });
|
||||
const seqPlacement = useWatch({ control, name: `${XH}.seqPlacement` }) as string | undefined;
|
||||
const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
|
||||
const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as
|
||||
| string
|
||||
| undefined;
|
||||
const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
|
||||
|
||||
function onXmuxMaxConcurrencyChange(value: unknown) {
|
||||
@@ -60,7 +64,10 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
|
||||
<FormField label={t('path')} name={['streamSettings', 'xhttpSettings', 'path']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.info.mode')} name={['streamSettings', 'xhttpSettings', 'mode']}>
|
||||
<FormField
|
||||
label={t('pages.inbounds.info.mode')}
|
||||
name={['streamSettings', 'xhttpSettings', 'mode']}
|
||||
>
|
||||
<Select options={MODE_OPTIONS} />
|
||||
</FormField>
|
||||
<FormField
|
||||
@@ -260,11 +267,7 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
|
||||
label={t('pages.xray.outboundForm.uplinkChunkSize')}
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
placeholder="0 (unlimited)"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<InputNumber min={0} placeholder="0 (unlimited)" style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,11 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import CountryPill from './CountryPill';
|
||||
@@ -77,24 +81,78 @@ export function useOutboundColumns({
|
||||
<div className="action-cell">
|
||||
<span className="row-index">{index + 1}</span>
|
||||
<div className="action-buttons">
|
||||
<Button shape="circle" size="small" icon={<EditOutlined />} aria-label={t('edit')} onClick={() => openEdit(index)} />
|
||||
<Button
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
aria-label={t('edit')}
|
||||
onClick={() => openEdit(index)}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
...(index > 0
|
||||
? [
|
||||
{ key: 'top', label: <><VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}</>, onClick: () => setFirst(index) },
|
||||
{
|
||||
key: 'top',
|
||||
label: (
|
||||
<>
|
||||
<VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}
|
||||
</>
|
||||
),
|
||||
onClick: () => setFirst(index),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ key: 'up', label: <><ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}</>, disabled: index === 0, onClick: () => moveUp(index) },
|
||||
{ key: 'down', label: <><ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}</>, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
|
||||
{ key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(rows[index].tag || '') },
|
||||
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
||||
{
|
||||
key: 'up',
|
||||
label: (
|
||||
<>
|
||||
<ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}
|
||||
</>
|
||||
),
|
||||
disabled: index === 0,
|
||||
onClick: () => moveUp(index),
|
||||
},
|
||||
{
|
||||
key: 'down',
|
||||
label: (
|
||||
<>
|
||||
<ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}
|
||||
</>
|
||||
),
|
||||
disabled: index === rows.length - 1,
|
||||
onClick: () => moveDown(index),
|
||||
},
|
||||
{
|
||||
key: 'reset',
|
||||
label: (
|
||||
<>
|
||||
<RetweetOutlined /> {t('pages.inbounds.resetTraffic')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onResetTraffic(rows[index].tag || ''),
|
||||
},
|
||||
{
|
||||
key: 'del',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => confirmDelete(index),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
|
||||
<Button
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<MoreOutlined />}
|
||||
aria-label={t('more')}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,10 +169,14 @@ export function useOutboundColumns({
|
||||
</Tooltip>
|
||||
<div className="protocol-line">
|
||||
<Tag color="green">{record.protocol}</Tag>
|
||||
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
|
||||
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
|
||||
record.protocol as never,
|
||||
) && (
|
||||
<>
|
||||
<Tag>{record.streamSettings?.network}</Tag>
|
||||
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
|
||||
{showSecurity(record.streamSettings?.security) && (
|
||||
<Tag color="purple">{record.streamSettings?.security}</Tag>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -148,9 +210,23 @@ export function useOutboundColumns({
|
||||
{t('pages.xray.outbound.egress')}
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showEgressIp ? (
|
||||
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowEgressIp(false)} onKeyDown={activateOnKey(() => setShowEgressIp(false))} />
|
||||
<EyeOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setShowEgressIp(false)}
|
||||
onKeyDown={activateOnKey(() => setShowEgressIp(false))}
|
||||
/>
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowEgressIp(true)} onKeyDown={activateOnKey(() => setShowEgressIp(true))} />
|
||||
<EyeInvisibleOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setShowEgressIp(true)}
|
||||
onKeyDown={activateOnKey(() => setShowEgressIp(true))}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
</span>
|
||||
@@ -177,7 +253,13 @@ export function useOutboundColumns({
|
||||
<Tooltip key={addr.label} title={addr.value}>
|
||||
<span className="egress-address">
|
||||
<span className="egress-family">{addr.label}</span>
|
||||
<span className={showEgressIp ? 'address-visible egress-ip' : 'address-hidden egress-ip'}>{addr.value}</span>
|
||||
<span
|
||||
className={
|
||||
showEgressIp ? 'address-visible egress-ip' : 'address-hidden egress-ip'
|
||||
}
|
||||
>
|
||||
{addr.value}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -202,7 +284,9 @@ export function useOutboundColumns({
|
||||
const flag = countryFlag(egress.country);
|
||||
const name = countryName(egress.country, i18n.language);
|
||||
return (
|
||||
<Tooltip title={egress.warp ? `Cloudflare trace · WARP ${egress.warp}` : 'Cloudflare trace'}>
|
||||
<Tooltip
|
||||
title={egress.warp ? `Cloudflare trace · WARP ${egress.warp}` : 'Cloudflare trace'}
|
||||
>
|
||||
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -231,7 +315,12 @@ export function useOutboundColumns({
|
||||
width: 140,
|
||||
render: (_v, record) => {
|
||||
const r = testResult(outboundTestStates, record.key);
|
||||
if (!r) return isTesting(outboundTestStates, record.key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||
if (!r)
|
||||
return isTesting(outboundTestStates, record.key) ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
<span className="empty">—</span>
|
||||
);
|
||||
return <TestResultPopover result={r} />;
|
||||
},
|
||||
},
|
||||
@@ -241,7 +330,9 @@ export function useOutboundColumns({
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_v, record) => (
|
||||
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
|
||||
<Tooltip
|
||||
title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
|
||||
@@ -13,7 +13,12 @@ interface NordModalProps {
|
||||
templateSettings: { outbounds?: { tag?: string }[] } | null;
|
||||
onClose: () => void;
|
||||
onAddOutbound: (outbound: Record<string, unknown>) => void;
|
||||
onResetOutbound: (payload: { index: number; outbound: Record<string, unknown>; oldTag?: string; newTag: string }) => void;
|
||||
onResetOutbound: (payload: {
|
||||
index: number;
|
||||
outbound: Record<string, unknown>;
|
||||
oldTag?: string;
|
||||
newTag: string;
|
||||
}) => void;
|
||||
onRemoveOutbound: (index: number) => void;
|
||||
onRemoveRoutingRules: (payload: { prefix: string }) => void;
|
||||
}
|
||||
@@ -129,7 +134,9 @@ export default function NordModal({
|
||||
async function login() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token: methods.getValues('token') });
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', {
|
||||
token: methods.getValues('token'),
|
||||
});
|
||||
if (msg?.success && msg.obj) {
|
||||
setNordData(JSON.parse(msg.obj));
|
||||
await fetchCountries();
|
||||
@@ -142,7 +149,9 @@ export default function NordModal({
|
||||
async function saveKey() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: methods.getValues('manualKey') });
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', {
|
||||
key: methods.getValues('manualKey'),
|
||||
});
|
||||
if (msg?.success && msg.obj) {
|
||||
setNordData(JSON.parse(msg.obj));
|
||||
await fetchCountries();
|
||||
@@ -177,7 +186,9 @@ export default function NordModal({
|
||||
methods.setValue('serverId', null);
|
||||
methods.setValue('cityId', null);
|
||||
try {
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', {
|
||||
countryId: newCountryId,
|
||||
});
|
||||
if (!msg?.success || !msg.obj) return;
|
||||
const data = JSON.parse(msg.obj);
|
||||
const locations = data.locations || [];
|
||||
@@ -256,147 +267,173 @@ export default function NordModal({
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
|
||||
<FormProvider {...methods}>
|
||||
{nordData == null ? (
|
||||
<Tabs
|
||||
defaultActiveKey="token"
|
||||
items={[
|
||||
{
|
||||
key: 'token',
|
||||
label: t('pages.xray.nord.accessToken'),
|
||||
children: (
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-20"
|
||||
>
|
||||
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
|
||||
<Input placeholder={t('pages.xray.nord.accessToken')} />
|
||||
</FormField>
|
||||
<Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
|
||||
{t('login')}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'key',
|
||||
label: t('pages.xray.nord.privateKey'),
|
||||
children: (
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-20"
|
||||
>
|
||||
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
|
||||
<Input placeholder={t('pages.xray.nord.privateKey')} />
|
||||
</FormField>
|
||||
<Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<table className="nord-data-table">
|
||||
<tbody>
|
||||
{nordData.token && (
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.nord.accessToken')}</td>
|
||||
<td>{nordData.token}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.xray.nord.privateKey')}</td>
|
||||
<td>{nordData.private_key}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
|
||||
{t('logout')}
|
||||
</Button>
|
||||
|
||||
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
||||
|
||||
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10">
|
||||
<FormField
|
||||
name="countryId"
|
||||
label={t('pages.xray.outbound.country')}
|
||||
transform={{ input: (v) => v ?? undefined }}
|
||||
onAfterChange={(v) => fetchServers(v as number)}
|
||||
>
|
||||
<Select
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={countries.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name} (${c.code})`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{cities.length > 0 && (
|
||||
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
|
||||
<Select
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={[{ value: null, label: t('pages.xray.outbound.allCities') }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{filteredServers.length > 0 && (
|
||||
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
|
||||
<Select
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={filteredServers.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.cityName} ${s.name} ${s.hostname}`,
|
||||
children: (
|
||||
<span className="server-row">
|
||||
<span className="server-name">
|
||||
{s.cityName} - {s.name}
|
||||
</span>
|
||||
<Tag color={loadColor(s.load)} className="server-load-tag">
|
||||
{s.load}%
|
||||
</Tag>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
|
||||
{nordOutboundIndex >= 0 ? (
|
||||
<>
|
||||
<Tag color="green">{t('enabled')}</Tag>
|
||||
<Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
|
||||
{t('reset')}
|
||||
</Button>
|
||||
</>
|
||||
<FormProvider {...methods}>
|
||||
{nordData == null ? (
|
||||
<Tabs
|
||||
defaultActiveKey="token"
|
||||
items={[
|
||||
{
|
||||
key: 'token',
|
||||
label: t('pages.xray.nord.accessToken'),
|
||||
children: (
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-20"
|
||||
>
|
||||
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
|
||||
<Input placeholder={t('pages.xray.nord.accessToken')} />
|
||||
</FormField>
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-10"
|
||||
loading={loading}
|
||||
icon={<LoginOutlined />}
|
||||
onClick={login}
|
||||
>
|
||||
{t('login')}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'key',
|
||||
label: t('pages.xray.nord.privateKey'),
|
||||
children: (
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-20"
|
||||
>
|
||||
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
|
||||
<Input placeholder={t('pages.xray.nord.privateKey')} />
|
||||
</FormField>
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-10"
|
||||
loading={loading}
|
||||
icon={<SaveOutlined />}
|
||||
onClick={saveKey}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Tag color="orange">{t('disabled')}</Tag>
|
||||
<Button
|
||||
type="primary"
|
||||
className="ml-8"
|
||||
disabled={!serverId}
|
||||
loading={loading}
|
||||
onClick={addOutbound}
|
||||
>
|
||||
{t('pages.xray.warp.addOutbound')}
|
||||
<table className="nord-data-table">
|
||||
<tbody>
|
||||
{nordData.token && (
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.nord.accessToken')}</td>
|
||||
<td>{nordData.token}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.xray.nord.privateKey')}</td>
|
||||
<td>{nordData.private_key}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
|
||||
{t('logout')}
|
||||
</Button>
|
||||
|
||||
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
||||
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-10"
|
||||
>
|
||||
<FormField
|
||||
name="countryId"
|
||||
label={t('pages.xray.outbound.country')}
|
||||
transform={{ input: (v) => v ?? undefined }}
|
||||
onAfterChange={(v) => fetchServers(v as number)}
|
||||
>
|
||||
<Select
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={countries.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name} (${c.code})`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{cities.length > 0 && (
|
||||
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
|
||||
<Select
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={[
|
||||
{ value: null, label: t('pages.xray.outbound.allCities') },
|
||||
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{filteredServers.length > 0 && (
|
||||
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
|
||||
<Select
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={filteredServers.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.cityName} ${s.name} ${s.hostname}`,
|
||||
children: (
|
||||
<span className="server-row">
|
||||
<span className="server-name">
|
||||
{s.cityName} - {s.name}
|
||||
</span>
|
||||
<Tag color={loadColor(s.load)} className="server-load-tag">
|
||||
{s.load}%
|
||||
</Tag>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
|
||||
{nordOutboundIndex >= 0 ? (
|
||||
<>
|
||||
<Tag color="green">{t('enabled')}</Tag>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
loading={loading}
|
||||
className="ml-8"
|
||||
onClick={resetOutbound}
|
||||
>
|
||||
{t('reset')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag color="orange">{t('disabled')}</Tag>
|
||||
<Button
|
||||
type="primary"
|
||||
className="ml-8"
|
||||
disabled={!serverId}
|
||||
loading={loading}
|
||||
onClick={addOutbound}
|
||||
>
|
||||
{t('pages.xray.warp.addOutbound')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Collapse,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import { Alert, Button, Collapse, Divider, Form, Input, message, Modal, Tag } from 'antd';
|
||||
import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
@@ -84,7 +74,9 @@ export function mergeWarpRotation(
|
||||
const peer = cfg?.peers?.[0];
|
||||
if (!cfg || !peer) return null;
|
||||
const base: Record<string, unknown> =
|
||||
existing && typeof existing === 'object' ? { ...existing } : { tag: 'warp', protocol: 'wireguard' };
|
||||
existing && typeof existing === 'object'
|
||||
? { ...existing }
|
||||
: { tag: 'warp', protocol: 'wireguard' };
|
||||
const prevSettings =
|
||||
base.settings && typeof base.settings === 'object'
|
||||
? { ...(base.settings as Record<string, unknown>) }
|
||||
@@ -251,7 +243,9 @@ export default function WarpModal({
|
||||
async function saveInterval() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: methods.getValues('updateInterval') });
|
||||
const msg = await HttpUtil.post('/panel/api/xray/warp/interval', {
|
||||
interval: methods.getValues('updateInterval'),
|
||||
});
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
|
||||
}
|
||||
@@ -266,7 +260,9 @@ export default function WarpModal({
|
||||
setLoading(true);
|
||||
setLicenseError('');
|
||||
try {
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: licenseValue });
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', {
|
||||
license: licenseValue,
|
||||
});
|
||||
if (msg?.success && msg.obj) {
|
||||
setWarpData(JSON.parse(msg.obj));
|
||||
setWarpConfig(null);
|
||||
@@ -317,167 +313,218 @@ export default function WarpModal({
|
||||
{messageContextHolder}
|
||||
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
|
||||
<FormProvider {...methods}>
|
||||
{!hasWarp ? (
|
||||
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
|
||||
{t('pages.xray.warp.createAccount')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<table className="warp-data-table">
|
||||
<tbody>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.accessToken')}</td>
|
||||
<td>{warpData?.access_token}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.deviceId')}</td>
|
||||
<td>{warpData?.device_id}</td>
|
||||
</tr>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.licenseKey')}</td>
|
||||
<td>{warpData?.license_key}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.privateKey')}</td>
|
||||
<td>{warpData?.private_key}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button loading={loading} type="primary" danger className="mt-8" icon={<DeleteOutlined />} onClick={delConfig}>
|
||||
{t('pages.xray.warp.deleteAccount')}
|
||||
{!hasWarp ? (
|
||||
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
|
||||
{t('pages.xray.warp.createAccount')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<table className="warp-data-table">
|
||||
<tbody>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.accessToken')}</td>
|
||||
<td>{warpData?.access_token}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.deviceId')}</td>
|
||||
<td>{warpData?.device_id}</td>
|
||||
</tr>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.licenseKey')}</td>
|
||||
<td>{warpData?.license_key}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.privateKey')}</td>
|
||||
<td>{warpData?.private_key}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
||||
<Button
|
||||
loading={loading}
|
||||
type="primary"
|
||||
danger
|
||||
className="mt-8"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={delConfig}
|
||||
>
|
||||
{t('pages.xray.warp.deleteAccount')}
|
||||
</Button>
|
||||
|
||||
<Collapse
|
||||
className="my-10"
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: t('pages.xray.warp.licenseKeyLabel'),
|
||||
children: (
|
||||
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
<FormField
|
||||
name="warpPlus"
|
||||
label={t('pages.xray.warp.key')}
|
||||
onAfterChange={() => setLicenseError('')}
|
||||
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
||||
|
||||
<Collapse
|
||||
className="my-10"
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: t('pages.xray.warp.licenseKeyLabel'),
|
||||
children: (
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 14 } }}
|
||||
>
|
||||
<Input placeholder={t('pages.xray.warp.keyPlaceholder')} />
|
||||
</FormField>
|
||||
<div className="license-actions mt-8">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={warpPlusValue.length < 26}
|
||||
loading={loading}
|
||||
onClick={updateLicense}
|
||||
<FormField
|
||||
name="warpPlus"
|
||||
label={t('pages.xray.warp.key')}
|
||||
onAfterChange={() => setLicenseError('')}
|
||||
>
|
||||
{t('update')}
|
||||
</Button>
|
||||
{licenseError && (
|
||||
<Alert title={licenseError} type="error" showIcon className="license-error" />
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
|
||||
children: (
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
|
||||
<FormField
|
||||
name="updateInterval"
|
||||
label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
|
||||
tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}
|
||||
transform={{ output: (v) => Number(v) }}
|
||||
<Input placeholder={t('pages.xray.warp.keyPlaceholder')} />
|
||||
</FormField>
|
||||
<div className="license-actions mt-8">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={warpPlusValue.length < 26}
|
||||
loading={loading}
|
||||
onClick={updateLicense}
|
||||
>
|
||||
{t('update')}
|
||||
</Button>
|
||||
{licenseError && (
|
||||
<Alert
|
||||
title={licenseError}
|
||||
type="error"
|
||||
showIcon
|
||||
className="license-error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
|
||||
children: (
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 8 } }}
|
||||
wrapperCol={{ md: { span: 12 } }}
|
||||
>
|
||||
<Input type="number" min={0} />
|
||||
</FormField>
|
||||
<Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<FormField
|
||||
name="updateInterval"
|
||||
label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
|
||||
tooltip={t(
|
||||
'pages.xray.warp.intervalDesc',
|
||||
'0 to disable. Changes IP address automatically.',
|
||||
)}
|
||||
transform={{ output: (v) => Number(v) }}
|
||||
>
|
||||
<Input type="number" min={0} />
|
||||
</FormField>
|
||||
<Button
|
||||
className="mt-8"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={saveInterval}
|
||||
>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
|
||||
<div className="my-8">
|
||||
<Button loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button loading={loading} type="primary" className="ml-8" icon={<SyncOutlined />} onClick={changeIp}>
|
||||
{t('pages.xray.warp.changeIp', 'Change IP')}
|
||||
</Button>
|
||||
</div>
|
||||
<Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
|
||||
<div className="my-8">
|
||||
<Button
|
||||
loading={loading}
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={getConfig}
|
||||
>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={loading}
|
||||
type="primary"
|
||||
className="ml-8"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={changeIp}
|
||||
>
|
||||
{t('pages.xray.warp.changeIp', 'Change IP')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasConfig && (
|
||||
<>
|
||||
<table className="warp-data-table">
|
||||
<tbody>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.deviceName')}</td>
|
||||
<td>{warpConfig?.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.deviceModel')}</td>
|
||||
<td>{warpConfig?.model}</td>
|
||||
</tr>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.deviceEnabled')}</td>
|
||||
<td>{String(warpConfig?.enabled)}</td>
|
||||
</tr>
|
||||
{warpConfig?.account && (
|
||||
<>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.accountType')}</td>
|
||||
<td>{warpConfig.account.account_type}</td>
|
||||
</tr>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.role')}</td>
|
||||
<td>{warpConfig.account.role}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.warpPlusData')}</td>
|
||||
<td>{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}</td>
|
||||
</tr>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.quota')}</td>
|
||||
<td>{SizeFormatter.sizeFormat(warpConfig.account.quota)}</td>
|
||||
</tr>
|
||||
{warpConfig.account.usage != null && (
|
||||
{hasConfig && (
|
||||
<>
|
||||
<table className="warp-data-table">
|
||||
<tbody>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.deviceName')}</td>
|
||||
<td>{warpConfig?.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.deviceModel')}</td>
|
||||
<td>{warpConfig?.model}</td>
|
||||
</tr>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.deviceEnabled')}</td>
|
||||
<td>{String(warpConfig?.enabled)}</td>
|
||||
</tr>
|
||||
{warpConfig?.account && (
|
||||
<>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.usage')}</td>
|
||||
<td>{SizeFormatter.sizeFormat(warpConfig.account.usage)}</td>
|
||||
<td>{t('pages.xray.warp.accountType')}</td>
|
||||
<td>{warpConfig.account.account_type}</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.role')}</td>
|
||||
<td>{warpConfig.account.role}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.warpPlusData')}</td>
|
||||
<td>{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}</td>
|
||||
</tr>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.warp.quota')}</td>
|
||||
<td>{SizeFormatter.sizeFormat(warpConfig.account.quota)}</td>
|
||||
</tr>
|
||||
{warpConfig.account.usage != null && (
|
||||
<tr>
|
||||
<td>{t('pages.xray.warp.usage')}</td>
|
||||
<td>{SizeFormatter.sizeFormat(warpConfig.account.usage)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
|
||||
{warpOutboundIndex >= 0 ? (
|
||||
<>
|
||||
<Tag color="green">{t('enabled')}</Tag>
|
||||
<Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
|
||||
{t('reset')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag color="orange">{t('disabled')}</Tag>
|
||||
<Button type="primary" loading={loading} className="ml-8" icon={<PlusOutlined />} onClick={addOutbound}>
|
||||
{t('pages.xray.warp.addOutbound')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
|
||||
{warpOutboundIndex >= 0 ? (
|
||||
<>
|
||||
<Tag color="green">{t('enabled')}</Tag>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
loading={loading}
|
||||
className="ml-8"
|
||||
onClick={resetOutbound}
|
||||
>
|
||||
{t('reset')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag color="orange">{t('disabled')}</Tag>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
className="ml-8"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addOutbound}
|
||||
>
|
||||
{t('pages.xray.warp.addOutbound')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -36,7 +36,12 @@ export interface DeletionImpact {
|
||||
burst: boolean;
|
||||
}
|
||||
|
||||
const emptyImpact = (): DeletionImpact => ({ rules: [], balancers: [], observatory: false, burst: false });
|
||||
const emptyImpact = (): DeletionImpact => ({
|
||||
rules: [],
|
||||
balancers: [],
|
||||
observatory: false,
|
||||
burst: false,
|
||||
});
|
||||
|
||||
function ruleList(tt: XraySettingsValue): RuleObject[] {
|
||||
const r = tt.routing?.rules;
|
||||
@@ -159,7 +164,11 @@ function applyCleanup(
|
||||
for (const outbound of tt.outbounds) {
|
||||
const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
|
||||
?.streamSettings?.sockopt;
|
||||
if (sockopt && typeof sockopt.dialerProxy === 'string' && removedOutbounds.has(sockopt.dialerProxy)) {
|
||||
if (
|
||||
sockopt &&
|
||||
typeof sockopt.dialerProxy === 'string' &&
|
||||
removedOutbounds.has(sockopt.dialerProxy)
|
||||
) {
|
||||
delete sockopt.dialerProxy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,17 @@ import { Tooltip } from 'antd';
|
||||
|
||||
import { csv } from './helpers';
|
||||
|
||||
export default function CriterionRow({ label, value, values, title }: { label: string; value?: string; values?: string[]; title: string }) {
|
||||
export default function CriterionRow({
|
||||
label,
|
||||
value,
|
||||
values,
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
values?: string[];
|
||||
title: string;
|
||||
}) {
|
||||
const parts = values ?? csv(value);
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
|
||||
@@ -105,7 +105,9 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
|
||||
allowClear
|
||||
value={inboundTag}
|
||||
onChange={setInboundTag}
|
||||
options={inboundTags.filter(Boolean).map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))}
|
||||
options={inboundTags
|
||||
.filter(Boolean)
|
||||
.map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
@@ -120,14 +122,21 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={fieldSpan} sm={3}>
|
||||
<Button type="primary" icon={<AimOutlined />} loading={testing} disabled={!dest.trim()} onClick={run} block>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AimOutlined />}
|
||||
loading={testing}
|
||||
disabled={!dest.trim()}
|
||||
onClick={run}
|
||||
block
|
||||
>
|
||||
{t('pages.xray.routeTesterTest')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{result && (
|
||||
result.matched ? (
|
||||
{result &&
|
||||
(result.matched ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
@@ -139,7 +148,9 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
|
||||
<>
|
||||
<span>{t('pages.xray.routeTesterViaBalancer')}:</span>
|
||||
{(result.groupTags || []).map((tag) => (
|
||||
<Tag key={tag} color="orange">{tag}</Tag>
|
||||
<Tag key={tag} color="orange">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -148,8 +159,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
|
||||
/>
|
||||
) : (
|
||||
<Alert type="warning" showIcon title={t('pages.xray.routeTesterDefaultOutbound')} />
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,13 @@ import {
|
||||
directSettings,
|
||||
ipv4Settings,
|
||||
} from '../basics/constants';
|
||||
import { getDefaultOutboundTag, ruleGetter, ruleSetter, setDefaultOutboundTag, syncOutbound } from '../basics/helpers';
|
||||
import {
|
||||
getDefaultOutboundTag,
|
||||
ruleGetter,
|
||||
ruleSetter,
|
||||
setDefaultOutboundTag,
|
||||
syncOutbound,
|
||||
} from '../basics/helpers';
|
||||
|
||||
interface RoutingBasicProps {
|
||||
templateSettings: XraySettingsValue | null;
|
||||
@@ -81,12 +87,14 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Switch
|
||||
checked={torrentActive}
|
||||
onChange={(checked) => mutate((tt) => {
|
||||
const next = checked
|
||||
? [...blockedProtocols, ...BITTORRENT_PROTOCOLS]
|
||||
: blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
|
||||
ruleSetter(tt, 'blocked', 'protocol', next);
|
||||
})}
|
||||
onChange={(checked) =>
|
||||
mutate((tt) => {
|
||||
const next = checked
|
||||
? [...blockedProtocols, ...BITTORRENT_PROTOCOLS]
|
||||
: blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
|
||||
ruleSetter(tt, 'blocked', 'protocol', next);
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -135,10 +143,12 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
value={directIPs}
|
||||
style={{ width: '100%' }}
|
||||
options={IPS_OPTIONS}
|
||||
onChange={(v) => mutate((tt) => {
|
||||
ruleSetter(tt, 'direct', 'ip', v);
|
||||
syncOutbound(tt, 'direct', directSettings);
|
||||
})}
|
||||
onChange={(v) =>
|
||||
mutate((tt) => {
|
||||
ruleSetter(tt, 'direct', 'ip', v);
|
||||
syncOutbound(tt, 'direct', directSettings);
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -152,10 +162,12 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
value={directDomains}
|
||||
style={{ width: '100%' }}
|
||||
options={DOMAINS_OPTIONS}
|
||||
onChange={(v) => mutate((tt) => {
|
||||
ruleSetter(tt, 'direct', 'domain', v);
|
||||
syncOutbound(tt, 'direct', directSettings);
|
||||
})}
|
||||
onChange={(v) =>
|
||||
mutate((tt) => {
|
||||
ruleSetter(tt, 'direct', 'domain', v);
|
||||
syncOutbound(tt, 'direct', directSettings);
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -170,10 +182,12 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
value={ipv4Domains}
|
||||
style={{ width: '100%' }}
|
||||
options={SERVICES_OPTIONS}
|
||||
onChange={(v) => mutate((tt) => {
|
||||
ruleSetter(tt, 'IPv4', 'domain', v);
|
||||
syncOutbound(tt, 'IPv4', ipv4Settings);
|
||||
})}
|
||||
onChange={(v) =>
|
||||
mutate((tt) => {
|
||||
ruleSetter(tt, 'IPv4', 'domain', v);
|
||||
syncOutbound(tt, 'IPv4', ipv4Settings);
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -112,7 +112,9 @@
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
transition: opacity 0.15s, box-shadow 0.15s;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.rule-list > .rule-card:not(:last-child)::after {
|
||||
|
||||
@@ -51,7 +51,12 @@ export default function RoutingTab({
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
||||
const dragRef = useRef<{ from: number | null; to: number | null; startY: number; moved: boolean }>({
|
||||
const dragRef = useRef<{
|
||||
from: number | null;
|
||||
to: number | null;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
}>({
|
||||
from: null,
|
||||
to: null,
|
||||
startY: 0,
|
||||
@@ -120,11 +125,15 @@ export default function RoutingTab({
|
||||
for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag);
|
||||
for (const tag of inboundTags || []) push(tag);
|
||||
for (const ob of templateSettings?.outbounds || []) {
|
||||
const obx = ob as { reverse?: { tag?: string }; settings?: { reverse?: { tag?: string }; inboundTag?: string } };
|
||||
const obx = ob as {
|
||||
reverse?: { tag?: string };
|
||||
settings?: { reverse?: { tag?: string }; inboundTag?: string };
|
||||
};
|
||||
push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag);
|
||||
}
|
||||
push((templateSettings?.dns as { tag?: string } | undefined)?.tag);
|
||||
for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)?.servers || []) {
|
||||
for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)
|
||||
?.servers || []) {
|
||||
if (typeof s === 'object' && s?.tag) push(s.tag);
|
||||
}
|
||||
return out;
|
||||
@@ -222,9 +231,10 @@ export default function RoutingTab({
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => mutate((tt) => {
|
||||
tt.routing?.rules?.splice(target, 1);
|
||||
}),
|
||||
onOk: () =>
|
||||
mutate((tt) => {
|
||||
tt.routing?.rules?.splice(target, 1);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,7 +272,9 @@ export default function RoutingTab({
|
||||
ev.preventDefault();
|
||||
try {
|
||||
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
|
||||
setDraggedIndex(idx);
|
||||
setDropTargetIndex(idx);
|
||||
@@ -357,8 +369,19 @@ export default function RoutingTab({
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'import', icon: <ImportOutlined />, label: t('pages.xray.importRules'), onClick: () => setImportOpen(true) },
|
||||
{ key: 'export', icon: <ExportOutlined />, label: t('pages.xray.exportRules'), disabled: rules.length === 0, onClick: exportRules },
|
||||
{
|
||||
key: 'import',
|
||||
icon: <ImportOutlined />,
|
||||
label: t('pages.xray.importRules'),
|
||||
onClick: () => setImportOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
icon: <ExportOutlined />,
|
||||
label: t('pages.xray.exportRules'),
|
||||
disabled: rules.length === 0,
|
||||
onClick: exportRules,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
@@ -394,7 +417,10 @@ export default function RoutingTab({
|
||||
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
|
||||
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
|
||||
}
|
||||
return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes<HTMLElement>;
|
||||
return {
|
||||
className: classes.join(' '),
|
||||
'data-row-key': i,
|
||||
} as React.HTMLAttributes<HTMLElement>;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, isApiRule, ruleCriteriaChips } from './helpers';
|
||||
import {
|
||||
buildRemarkByTag,
|
||||
chipPreview,
|
||||
inboundTagChipPreview,
|
||||
inboundTagsDisplayTitle,
|
||||
isApiRule,
|
||||
ruleCriteriaChips,
|
||||
} from './helpers';
|
||||
import type { RuleRow } from './types';
|
||||
|
||||
interface RuleCardListProps {
|
||||
@@ -51,7 +58,9 @@ export default function RuleCardList({
|
||||
<div
|
||||
key={rule.key}
|
||||
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
|
||||
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
|
||||
dropTargetIndex === index && draggedIndex != null && index < draggedIndex
|
||||
? 'drop-before'
|
||||
: ''
|
||||
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''} ${
|
||||
rule.enabled === false ? 'rule-disabled' : ''
|
||||
}`}
|
||||
@@ -68,14 +77,54 @@ export default function RuleCardList({
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
|
||||
{ key: 'up', label: <><ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}</>, disabled: index === 0, onClick: () => moveUp(index) },
|
||||
{ key: 'down', label: <><ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}</>, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
|
||||
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => openEdit(index),
|
||||
},
|
||||
{
|
||||
key: 'up',
|
||||
label: (
|
||||
<>
|
||||
<ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}
|
||||
</>
|
||||
),
|
||||
disabled: index === 0,
|
||||
onClick: () => moveUp(index),
|
||||
},
|
||||
{
|
||||
key: 'down',
|
||||
label: (
|
||||
<>
|
||||
<ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}
|
||||
</>
|
||||
),
|
||||
disabled: index === rows.length - 1,
|
||||
onClick: () => moveDown(index),
|
||||
},
|
||||
{
|
||||
key: 'del',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => confirmDelete(index),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
|
||||
<Button
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<MoreOutlined />}
|
||||
aria-label={t('more')}
|
||||
/>
|
||||
</Dropdown>
|
||||
<Switch
|
||||
size="small"
|
||||
@@ -102,7 +151,9 @@ export default function RuleCardList({
|
||||
<span className="flow-arrow">→</span>
|
||||
<div className="flow-side flow-side-target">
|
||||
<span className="flow-label">
|
||||
{rule.balancerTag ? t('pages.xray.balancer') || 'Balancer' : t('pages.xray.Outbounds')}
|
||||
{rule.balancerTag
|
||||
? t('pages.xray.balancer') || 'Balancer'
|
||||
: t('pages.xray.Outbounds')}
|
||||
</span>
|
||||
{rule.outboundTag ? (
|
||||
<Tag color="green" className="flow-tag">
|
||||
|
||||
@@ -61,7 +61,10 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
|
||||
|
||||
function csv(value: string): string[] {
|
||||
if (!value) return [];
|
||||
return value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function RuleFormModal({
|
||||
@@ -224,7 +227,9 @@ export default function RuleFormModal({
|
||||
aria-label={t('pages.nodes.name')}
|
||||
placeholder={t('pages.nodes.name')}
|
||||
onChange={(e) => {
|
||||
const next = attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
|
||||
const next = attrs.map((a, i) =>
|
||||
i === idx ? ([e.target.value, a[1]] as [string, string]) : a,
|
||||
);
|
||||
methods.setValue('attrs', next);
|
||||
}}
|
||||
/>
|
||||
@@ -233,14 +238,21 @@ export default function RuleFormModal({
|
||||
aria-label={t('pages.xray.ruleForm.value')}
|
||||
placeholder={t('pages.xray.ruleForm.value')}
|
||||
onChange={(e) => {
|
||||
const next = attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
|
||||
const next = attrs.map((a, i) =>
|
||||
i === idx ? ([a[0], e.target.value] as [string, string]) : a,
|
||||
);
|
||||
methods.setValue('attrs', next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('remove')}
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() => methods.setValue('attrs', attrs.filter((_, i) => i !== idx))}
|
||||
onClick={() =>
|
||||
methods.setValue(
|
||||
'attrs',
|
||||
attrs.filter((_, i) => i !== idx),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
))}
|
||||
@@ -293,7 +305,10 @@ export default function RuleFormModal({
|
||||
<FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
|
||||
options={inboundTags.map((tag) => ({
|
||||
value: tag,
|
||||
label: formatInboundTag(tag, remarkByTag),
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ export function originalRuleIndex(rows: RuleRow[], positionalIndex: number): num
|
||||
|
||||
export function csv(value?: string): string[] {
|
||||
if (!value) return [];
|
||||
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function chipPreviewParts(parts: string[]): string {
|
||||
@@ -45,10 +48,7 @@ export function buildRemarkByTag(
|
||||
}
|
||||
|
||||
/** Format a single inbound tag as `tag (remark)`, or just `tag` when no distinct remark. */
|
||||
export function formatInboundTag(
|
||||
tag: string,
|
||||
remarkByTag: Record<string, string> = {},
|
||||
): string {
|
||||
export function formatInboundTag(tag: string, remarkByTag: Record<string, string> = {}): string {
|
||||
const label = remarkByTag[tag]?.trim();
|
||||
if (!label || label === tag) return tag;
|
||||
return `${tag} (${label})`;
|
||||
|
||||
@@ -15,7 +15,12 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import CriterionRow from './CriterionRow';
|
||||
import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle, isApiRule } from './helpers';
|
||||
import {
|
||||
buildRemarkByTag,
|
||||
formatInboundTagList,
|
||||
inboundTagsDisplayTitle,
|
||||
isApiRule,
|
||||
} from './helpers';
|
||||
import type { RuleRow } from './types';
|
||||
|
||||
interface RoutingColumnsParams {
|
||||
@@ -71,25 +76,66 @@ export function useRoutingColumns({
|
||||
width: 80,
|
||||
key: 'action',
|
||||
render: (_v, _r, index) => (
|
||||
<div className={!isMobile ? 'action-buttons' : ''} style={{ justifyContent: 'center', margin: 0 }}>
|
||||
<div
|
||||
className={!isMobile ? 'action-buttons' : ''}
|
||||
style={{ justifyContent: 'center', margin: 0 }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Button shape="circle" size="small" icon={<EditOutlined />} aria-label={t('edit')} onClick={() => openEdit(index)} />
|
||||
<Button
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
aria-label={t('edit')}
|
||||
onClick={() => openEdit(index)}
|
||||
/>
|
||||
)}
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
...(isMobile
|
||||
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
|
||||
? [
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => openEdit(index),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ key: 'up', label: <><ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}</>, disabled: index === 0, onClick: () => moveUp(index) },
|
||||
{
|
||||
key: 'up',
|
||||
label: (
|
||||
<>
|
||||
<ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}
|
||||
</>
|
||||
),
|
||||
disabled: index === 0,
|
||||
onClick: () => moveUp(index),
|
||||
},
|
||||
{
|
||||
key: 'down',
|
||||
label: <><ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}</>,
|
||||
label: (
|
||||
<>
|
||||
<ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}
|
||||
</>
|
||||
),
|
||||
disabled: index === rowsLength - 1,
|
||||
onClick: () => moveDown(index),
|
||||
},
|
||||
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
||||
{
|
||||
key: 'del',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => confirmDelete(index),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
@@ -120,10 +166,30 @@ export function useRoutingColumns({
|
||||
hidden: !showSource,
|
||||
render: (_v, record) => (
|
||||
<div className="criterion-flow">
|
||||
{record.sourceIP && <CriterionRow label="IP" value={record.sourceIP} title={`Source IP: ${record.sourceIP}`} />}
|
||||
{record.sourcePort && <CriterionRow label="Port" value={record.sourcePort} title={`Source port: ${record.sourcePort}`} />}
|
||||
{record.vlessRoute && <CriterionRow label="VLESS" value={record.vlessRoute} title={`VLESS route: ${record.vlessRoute}`} />}
|
||||
{!record.sourceIP && !record.sourcePort && !record.vlessRoute && <span className="criterion-empty">—</span>}
|
||||
{record.sourceIP && (
|
||||
<CriterionRow
|
||||
label="IP"
|
||||
value={record.sourceIP}
|
||||
title={`Source IP: ${record.sourceIP}`}
|
||||
/>
|
||||
)}
|
||||
{record.sourcePort && (
|
||||
<CriterionRow
|
||||
label="Port"
|
||||
value={record.sourcePort}
|
||||
title={`Source port: ${record.sourcePort}`}
|
||||
/>
|
||||
)}
|
||||
{record.vlessRoute && (
|
||||
<CriterionRow
|
||||
label="VLESS"
|
||||
value={record.vlessRoute}
|
||||
title={`VLESS route: ${record.vlessRoute}`}
|
||||
/>
|
||||
)}
|
||||
{!record.sourceIP && !record.sourcePort && !record.vlessRoute && (
|
||||
<span className="criterion-empty">—</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -134,10 +200,26 @@ export function useRoutingColumns({
|
||||
key: 'network',
|
||||
render: (_v, record) => (
|
||||
<div className="criterion-flow">
|
||||
{record.network && <CriterionRow label="L4" value={record.network.toUpperCase()} title={`L4: ${record.network.toUpperCase()}`} />}
|
||||
{record.protocol && <CriterionRow label="Protocol" value={record.protocol} title={`Protocol: ${record.protocol}`} />}
|
||||
{record.attrs && <CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />}
|
||||
{!record.network && !record.protocol && !record.attrs && <span className="criterion-empty">—</span>}
|
||||
{record.network && (
|
||||
<CriterionRow
|
||||
label="L4"
|
||||
value={record.network.toUpperCase()}
|
||||
title={`L4: ${record.network.toUpperCase()}`}
|
||||
/>
|
||||
)}
|
||||
{record.protocol && (
|
||||
<CriterionRow
|
||||
label="Protocol"
|
||||
value={record.protocol}
|
||||
title={`Protocol: ${record.protocol}`}
|
||||
/>
|
||||
)}
|
||||
{record.attrs && (
|
||||
<CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />
|
||||
)}
|
||||
{!record.network && !record.protocol && !record.attrs && (
|
||||
<span className="criterion-empty">—</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -148,10 +230,26 @@ export function useRoutingColumns({
|
||||
key: 'destination',
|
||||
render: (_v, record) => (
|
||||
<div className="criterion-flow">
|
||||
{record.ip && <CriterionRow label="IP" value={record.ip} title={`Destination IP: ${record.ip}`} />}
|
||||
{record.domain && <CriterionRow label="Domain" value={record.domain} title={`Domain: ${record.domain}`} />}
|
||||
{record.port && <CriterionRow label="Port" value={record.port} title={`Destination port: ${record.port}`} />}
|
||||
{!record.ip && !record.domain && !record.port && <span className="criterion-empty">—</span>}
|
||||
{record.ip && (
|
||||
<CriterionRow label="IP" value={record.ip} title={`Destination IP: ${record.ip}`} />
|
||||
)}
|
||||
{record.domain && (
|
||||
<CriterionRow
|
||||
label="Domain"
|
||||
value={record.domain}
|
||||
title={`Domain: ${record.domain}`}
|
||||
/>
|
||||
)}
|
||||
{record.port && (
|
||||
<CriterionRow
|
||||
label="Port"
|
||||
value={record.port}
|
||||
title={`Destination port: ${record.port}`}
|
||||
/>
|
||||
)}
|
||||
{!record.ip && !record.domain && !record.port && (
|
||||
<span className="criterion-empty">—</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -171,8 +269,12 @@ export function useRoutingColumns({
|
||||
title={`Inbound tag: ${inboundTagsDisplayTitle(record.inboundTag, remarkByTag) ?? inboundParts.join(', ')}`}
|
||||
/>
|
||||
)}
|
||||
{record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
|
||||
{inboundParts.length === 0 && !record.user && <span className="criterion-empty">—</span>}
|
||||
{record.user && (
|
||||
<CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />
|
||||
)}
|
||||
{inboundParts.length === 0 && !record.user && (
|
||||
<span className="criterion-empty">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -209,6 +311,19 @@ export function useRoutingColumns({
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete, toggleRule],
|
||||
[
|
||||
t,
|
||||
isMobile,
|
||||
rowsLength,
|
||||
showSource,
|
||||
showBalancer,
|
||||
remarkByTag,
|
||||
onHandlePointerDown,
|
||||
openEdit,
|
||||
moveUp,
|
||||
moveDown,
|
||||
confirmDelete,
|
||||
toggleRule,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user