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:
Sanaei
2026-08-19 15:36:27 +02:00
committed by GitHub
parent 380aff4d82
commit 92fb94d856
388 changed files with 19613 additions and 14133 deletions
+5 -5
View File
@@ -152,11 +152,11 @@
background: rgba(255, 255, 255, 0.06);
}
.api-docs-page.is-dark .swagger-ui input[type=text],
.api-docs-page.is-dark .swagger-ui input[type=password],
.api-docs-page.is-dark .swagger-ui input[type=search],
.api-docs-page.is-dark .swagger-ui input[type=email],
.api-docs-page.is-dark .swagger-ui input[type=file],
.api-docs-page.is-dark .swagger-ui input[type='text'],
.api-docs-page.is-dark .swagger-ui input[type='password'],
.api-docs-page.is-dark .swagger-ui input[type='search'],
.api-docs-page.is-dark .swagger-ui input[type='email'],
.api-docs-page.is-dark .swagger-ui input[type='file'],
.api-docs-page.is-dark .swagger-ui textarea {
background: var(--sw-bg-input);
color: var(--sw-text);
File diff suppressed because it is too large Load Diff
@@ -42,7 +42,9 @@ export default function BulkAddToGroupModal({
const result = await onSubmit(next);
if (result) {
const affected = result.affected ?? 0;
messageApi.success(t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }));
messageApi.success(
t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }),
);
onOpenChange(false);
}
} finally {
@@ -7,7 +7,15 @@ import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkAttachResult } from '@/schemas/client';
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
const MULTI_USER_PROTOCOLS = new Set([
'vmess',
'vless',
'trojan',
'hysteria',
'shadowsocks',
'wireguard',
'mtproto',
]);
interface BulkAttachInboundsModalProps {
open: boolean;
@@ -7,7 +7,15 @@ import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkDetachResult } from '@/schemas/client';
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
const MULTI_USER_PROTOCOLS = new Set([
'vmess',
'vless',
'trojan',
'hysteria',
'shadowsocks',
'wireguard',
'mtproto',
]);
interface BulkDetachInboundsModalProps {
open: boolean;
@@ -1,6 +1,18 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tooltip, message } from 'antd';
import {
AutoComplete,
Button,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Tooltip,
message,
} from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
@@ -18,7 +30,12 @@ import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
'shadowsocks',
'vless',
'vmess',
'trojan',
'hysteria',
'wireguard',
]);
const EMPTY: ClientBulkAddFormValues = {
@@ -83,7 +100,6 @@ export default function ClientBulkAddModal({
methods.reset(EMPTY);
setDelayedStart(false);
}, [open, methods]);
const flowCapableIds = useMemo(() => {
@@ -110,18 +126,18 @@ export default function ClientBulkAddModal({
useEffect(() => {
if (!showFlow && flow) {
methods.setValue('flow', '');
}
}, [showFlow, flow, methods]);
const inboundOptions = useMemo(
() => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.map((ib) => ({
label: formatInboundLabel(ib.tag, ib.remark),
value: ib.id,
})),
() =>
(inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.map((ib) => ({
label: formatInboundLabel(ib.tag, ib.remark),
value: ib.id,
})),
[inbounds],
);
@@ -177,7 +193,7 @@ export default function ClientBulkAddModal({
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
flow: showFlow ? (current.flow || '') : '',
flow: showFlow ? current.flow || '' : '',
totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
expiryTime: current.expiryTime,
reset: Number(current.reset) || 0,
@@ -201,9 +217,11 @@ export default function ClientBulkAddModal({
if (failed === 0 && msg?.success) {
messageApi.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
} else {
messageApi.warning(firstError
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
messageApi.warning(
firstError
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }),
);
}
onSaved?.();
onOpenChange(false);
@@ -241,7 +259,8 @@ export default function ClientBulkAddModal({
options={inboundOptions}
placeholder={t('pages.clients.selectInbound')}
showSearch={{
filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
filterOption: (input, option) =>
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
}}
/>
</Form.Item>
@@ -260,10 +279,18 @@ export default function ClientBulkAddModal({
{emailMethod > 1 && (
<>
<FormField name="firstNum" label={t('pages.clients.first')} transform={{ output: (v) => Number(v) || 1 }}>
<FormField
name="firstNum"
label={t('pages.clients.first')}
transform={{ output: (v) => Number(v) || 1 }}
>
<InputNumber min={1} />
</FormField>
<FormField name="lastNum" label={t('pages.clients.last')} transform={{ output: (v) => Number(v) || 1 }}>
<FormField
name="lastNum"
label={t('pages.clients.last')}
transform={{ output: (v) => Number(v) || 1 }}
>
<InputNumber min={firstNum} />
</FormField>
</>
@@ -279,7 +306,11 @@ export default function ClientBulkAddModal({
</FormField>
)}
{emailMethod < 2 && (
<FormField name="quantity" label={t('pages.clients.clientCount')} transform={{ output: (v) => Number(v) || 1 }}>
<FormField
name="quantity"
label={t('pages.clients.clientCount')}
transform={{ output: (v) => Number(v) || 1 }}
>
<InputNumber min={1} max={1000} />
</FormField>
)}
@@ -340,21 +371,32 @@ export default function ClientBulkAddModal({
<Form.Item label={t('pages.clients.limitIp')}>
<Tooltip title={limitIpNotice || undefined}>
<span style={{ display: 'inline-flex' }}>
<InputNumber value={limitIp} min={0} disabled={limitIpDisabled}
<InputNumber
value={limitIp}
min={0}
disabled={limitIpDisabled}
style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
onChange={(v) => methods.setValue('limitIp', Number(v) || 0)} />
onChange={(v) => methods.setValue('limitIp', Number(v) || 0)}
/>
</span>
</Tooltip>
</Form.Item>
<FormField name="totalGB" label={t('pages.clients.totalGB')} transform={{ output: (v) => Number(v) || 0 }}>
<FormField
name="totalGB"
label={t('pages.clients.totalGB')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} step={1} />
</FormField>
<Form.Item label={t('pages.clients.delayedStart')}>
<Switch
checked={delayedStart}
onClick={() => { setDelayedStart(!delayedStart); methods.setValue('expiryTime', 0); }}
onClick={() => {
setDelayedStart(!delayedStart);
methods.setValue('expiryTime', 0);
}}
/>
</Form.Item>
@@ -17,10 +17,19 @@ interface ClientBulkAdjustModalProps {
open: boolean;
count: number;
onOpenChange: (open: boolean) => void;
onSubmit: (addDays: number, addBytes: number, flow: string) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
onSubmit: (
addDays: number,
addBytes: number,
flow: string,
) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
}
export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
export default function ClientBulkAdjustModal({
open,
count,
onOpenChange,
onSubmit,
}: ClientBulkAdjustModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [submitting, setSubmitting] = useState(false);
@@ -53,9 +62,11 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
messageApi.success(t('pages.clients.toasts.bulkAdjusted', { count: ok }));
} else {
const firstReason = result.skipped?.[0]?.reason ?? '';
messageApi.warning(firstReason
? `${t('pages.clients.toasts.bulkAdjustedMixed', { ok, skipped })} — ${firstReason}`
: t('pages.clients.toasts.bulkAdjustedMixed', { ok, skipped }));
messageApi.warning(
firstReason
? `${t('pages.clients.toasts.bulkAdjustedMixed', { ok, skipped })} — ${firstReason}`
: t('pages.clients.toasts.bulkAdjustedMixed', { ok, skipped }),
);
}
onOpenChange(false);
} finally {
+403 -164
View File
@@ -19,7 +19,13 @@ import {
Typography,
message,
} from 'antd';
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
import {
DeleteOutlined,
EyeOutlined,
PlusOutlined,
ReloadOutlined,
RetweetOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { Controller, FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
@@ -31,16 +37,26 @@ import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
import type {
ClientRecord,
InboundOption,
ExternalLink,
ExternalLinkInput,
} from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto',
'shadowsocks',
'vless',
'vmess',
'trojan',
'hysteria',
'wireguard',
'mtproto',
]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -180,7 +196,10 @@ export function gbToBytes(gb: number): number {
return Math.round(gb * 1024 * 1024 * 1024);
}
export function resolveTotalBytes(originalBytes: number | null | undefined, displayedGB: number): number {
export function resolveTotalBytes(
originalBytes: number | null | undefined,
displayedGB: number,
): number {
if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) {
return originalBytes;
}
@@ -269,9 +288,10 @@ export default function ClientFormModal({
password: client.password || '',
auth: client.auth || '',
flow: client.flow || '',
security: !client.security || client.security === 'none' || client.security === 'zero'
? 'auto'
: client.security,
security:
!client.security || client.security === 'none' || client.security === 'zero'
? 'auto'
: client.security,
reverseTag: client.reverse?.tag || '',
totalGB: bytesToGB(client.totalGB || 0),
reset: Number(client.reset) || 0,
@@ -381,9 +401,12 @@ export default function ClientFormModal({
}, [inboundIds, inbounds]);
function regeneratePassword() {
methods.setValue('password', ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16));
methods.setValue(
'password',
ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16),
);
}
const showFlow = useMemo(
@@ -453,14 +476,15 @@ export default function ClientFormModal({
}, [showMtproto, secret, mtprotoDomain, methods]);
const inboundOptions = useMemo(
() => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
.map((ib) => ({
label: formatInboundLabel(ib.tag, ib.remark),
value: ib.id,
title: formatInboundLabel(ib.tag, ib.remark),
})),
() =>
(inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
.map((ib) => ({
label: formatInboundLabel(ib.tag, ib.remark),
value: ib.id,
title: formatInboundLabel(ib.tag, ib.remark),
})),
[inbounds, inboundIds],
);
@@ -480,8 +504,13 @@ export default function ClientFormModal({
if (!isEdit || !client?.email) return;
setIpsLoading(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
if (!msg?.success) { setClientIps([]); return; }
const msg = (await HttpUtil.post(
`/panel/api/clients/ips/${encodeURIComponent(client.email)}`,
)) as ApiMsg<unknown[]>;
if (!msg?.success) {
setClientIps([]);
return;
}
setClientIps(normalizeClientIps(msg.obj));
} finally {
setIpsLoading(false);
@@ -497,7 +526,9 @@ export default function ClientFormModal({
if (!isEdit || !client?.email) return;
setIpsClearing(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg;
const msg = (await HttpUtil.post(
`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`,
)) as ApiMsg;
if (msg?.success) setClientIps([]);
} finally {
setIpsClearing(false);
@@ -508,9 +539,19 @@ export default function ClientFormModal({
if (!isEdit || !client?.email) return;
setHwidsLoading(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
if (!msg?.success || !Array.isArray(msg.obj)) { setClientHwids([]); return; }
setClientHwids(msg.obj.filter((x): x is ClientHwidInfo => !!x && typeof x === 'object' && typeof (x as ClientHwidInfo).id === 'number'));
const msg = (await HttpUtil.post(
`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`,
)) as ApiMsg<unknown[]>;
if (!msg?.success || !Array.isArray(msg.obj)) {
setClientHwids([]);
return;
}
setClientHwids(
msg.obj.filter(
(x): x is ClientHwidInfo =>
!!x && typeof x === 'object' && typeof (x as ClientHwidInfo).id === 'number',
),
);
} finally {
setHwidsLoading(false);
}
@@ -525,7 +566,9 @@ export default function ClientFormModal({
if (!isEdit || !client?.email) return;
setHwidsClearing(true);
try {
const msg = await HttpUtil.delete(`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`) as ApiMsg;
const msg = (await HttpUtil.delete(
`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`,
)) as ApiMsg;
if (msg?.success) setClientHwids([]);
} finally {
setHwidsClearing(false);
@@ -555,7 +598,7 @@ export default function ClientFormModal({
const values = methods.getValues();
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
const validated = schema.safeParse({
email: values.email,
email: values.email,
subId: values.subId,
uuid: values.uuid,
password: values.password,
@@ -586,7 +629,7 @@ email: values.email,
}
const expiryTime = values.delayedStart
? -86400000 * (Number(values.delayedDays) || 0)
: (values.expiryDate || 0);
: values.expiryDate || 0;
const totalBytes = resolveTotalBytes(client ? (client.totalGB ?? 0) : null, values.totalGB);
const clientPayload: Record<string, unknown> = {
email: values.email.trim(),
@@ -594,11 +637,11 @@ email: values.email,
id: values.uuid,
password: values.password,
auth: values.auth,
flow: showFlow ? (values.flow || '') : '',
security: showSecurity ? (values.security || 'auto') : 'auto',
flow: showFlow ? values.flow || '' : '',
security: showSecurity ? values.security || 'auto' : 'auto',
totalGB: totalBytes,
expiryTime,
reset: Number(values.reset) || 0,
reset: Number(values.reset) || 0,
resetDay: Number(values.resetDay) || 0,
resetMax: Number(values.resetMax) || 0,
trafficReset: values.trafficReset || 'never',
@@ -688,7 +731,9 @@ reset: Number(values.reset) || 0,
width={720}
zIndex={CLIENT_FORM_MODAL_Z_INDEX}
style={{ top: 20 }}
styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }}
styles={{
body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' },
}}
onCancel={close}
footer={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -701,7 +746,12 @@ reset: Number(values.reset) || 0,
zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
onConfirm={onResetTraffic}
>
<Button color="danger" variant="filled" icon={<RetweetOutlined />} loading={resetting}>
<Button
color="danger"
variant="filled"
icon={<RetweetOutlined />}
loading={resetting}
>
{t('pages.inbounds.resetTraffic')}
</Button>
</Popconfirm>
@@ -715,7 +765,7 @@ reset: Number(values.reset) || 0,
</div>
}
>
<FormProvider {...methods}>
<FormProvider {...methods}>
<Form layout="vertical">
<Tabs
defaultActiveKey="basic"
@@ -736,7 +786,13 @@ reset: Number(values.reset) || 0,
onChange={(e) => methods.setValue('email', e.target.value)}
/>
{!isEdit && (
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('email', RandomUtil.randomLowerAndNum(12))} />
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() =>
methods.setValue('email', RandomUtil.randomLowerAndNum(12))
}
/>
)}
</Space.Compact>
</Form.Item>
@@ -752,16 +808,31 @@ reset: Number(values.reset) || 0,
</FormField>
</Col>
<Col xs={24} md={6}>
<Form.Item label={t('pages.clients.limitIp')} tooltip={t('pages.clients.limitIpDesc')}>
<Form.Item
label={t('pages.clients.limitIp')}
tooltip={t('pages.clients.limitIpDesc')}
>
<Tooltip title={limitIpNotice || undefined}>
<span style={{ display: 'flex', width: '100%' }}>
<Space.Compact style={{ display: 'flex', flex: 1 }}>
<InputNumber value={limitIp} min={0} disabled={limitIpDisabled}
style={{ flex: 1, ...(limitIpDisabled ? { pointerEvents: 'none' } : null) }}
onChange={(v) => methods.setValue('limitIp', Number(v) || 0)} />
<InputNumber
value={limitIp}
min={0}
disabled={limitIpDisabled}
style={{
flex: 1,
...(limitIpDisabled ? { pointerEvents: 'none' } : null),
}}
onChange={(v) => methods.setValue('limitIp', Number(v) || 0)}
/>
{isEdit && (
<Tooltip title={t('pages.clients.ipLog')}>
<Button aria-label={t('pages.clients.ipLog')} icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
<Button
aria-label={t('pages.clients.ipLog')}
icon={<EyeOutlined />}
loading={ipsLoading}
onClick={openIpsModal}
>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</Tooltip>
@@ -772,13 +843,25 @@ reset: Number(values.reset) || 0,
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item label={t('pages.clients.limitHwid')} tooltip={t('pages.clients.limitHwidDesc')}>
<Form.Item
label={t('pages.clients.limitHwid')}
tooltip={t('pages.clients.limitHwidDesc')}
>
<Space.Compact style={{ display: 'flex' }}>
<InputNumber value={limitHwid} min={0} style={{ flex: 1 }}
onChange={(v) => methods.setValue('limitHwid', Number(v) || 0)} />
<InputNumber
value={limitHwid}
min={0}
style={{ flex: 1 }}
onChange={(v) => methods.setValue('limitHwid', Number(v) || 0)}
/>
{isEdit && (
<Tooltip title={t('pages.clients.hwidLog')}>
<Button aria-label={t('pages.clients.hwidLog')} icon={<EyeOutlined />} loading={hwidsLoading} onClick={openHwidsModal}>
<Button
aria-label={t('pages.clients.hwidLog')}
icon={<EyeOutlined />}
loading={hwidsLoading}
onClick={openHwidsModal}
>
{clientHwids.length > 0 ? clientHwids.length : ''}
</Button>
</Tooltip>
@@ -802,7 +885,9 @@ reset: Number(values.reset) || 0,
<Form.Item label={t('pages.clients.expiryTime')}>
<DateTimePicker
value={expiryDayjs}
onChange={(d) => methods.setValue('expiryDate', d ? d.valueOf() : 0)}
onChange={(d) =>
methods.setValue('expiryDate', d ? d.valueOf() : 0)
}
/>
</Form.Item>
)}
@@ -906,8 +991,12 @@ reset: Number(values.reset) || 0,
label={t('pages.clients.telegramId')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} controls={false}
placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }} />
<InputNumber
min={0}
controls={false}
placeholder={t('pages.clients.telegramIdPlaceholder')}
style={{ width: '100%' }}
/>
</FormField>
</Col>
)}
@@ -937,13 +1026,20 @@ reset: Number(values.reset) || 0,
placement="topLeft"
listHeight={220}
showSearch={{
filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
filterOption: (input, option) =>
((option?.label as string) || '')
.toLowerCase()
.includes(input.toLowerCase()),
}}
/>
</Form.Item>
<Form.Item>
<Switch aria-label={t('enable')} checked={enable} onChange={(v) => methods.setValue('enable', v)} />
<Switch
aria-label={t('enable')}
checked={enable}
onChange={(v) => methods.setValue('enable', v)}
/>
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
</Form.Item>
</>
@@ -956,29 +1052,71 @@ reset: Number(values.reset) || 0,
<>
<Form.Item label={t('pages.clients.uuid')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={uuid} style={{ flex: 1 }} onChange={(e) => methods.setValue('uuid', e.target.value)} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())} />
<Input
value={uuid}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('uuid', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())}
/>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.password')} tooltip={t('pages.clients.passwordDesc')}>
<Form.Item
label={t('pages.clients.password')}
tooltip={t('pages.clients.passwordDesc')}
>
<Space.Compact style={{ display: 'flex' }}>
<Input value={password} style={{ flex: 1 }} onChange={(e) => methods.setValue('password', e.target.value)} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regeneratePassword} />
<Input
value={password}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('password', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={regeneratePassword}
/>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={subId} style={{ flex: 1 }} onChange={(e) => methods.setValue('subId', e.target.value)} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))} />
<Input
value={subId}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('subId', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() =>
methods.setValue('subId', RandomUtil.randomLowerAndNum(16))
}
/>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.hysteriaAuth')} tooltip={t('pages.clients.hysteriaAuthDesc')}>
<Form.Item
label={t('pages.clients.hysteriaAuth')}
tooltip={t('pages.clients.hysteriaAuthDesc')}
>
<Space.Compact style={{ display: 'flex' }}>
<Input value={auth} style={{ flex: 1 }} onChange={(e) => methods.setValue('auth', e.target.value)} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('auth', RandomUtil.randomLowerAndNum(16))} />
<Input
value={auth}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('auth', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() =>
methods.setValue('auth', RandomUtil.randomLowerAndNum(16))
}
/>
</Space.Compact>
</Form.Item>
@@ -1009,16 +1147,29 @@ reset: Number(values.reset) || 0,
onChange={(e) => {
const priv = e.target.value;
methods.setValue('wgPrivateKey', priv);
methods.setValue('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
methods.setValue(
'wgPublicKey',
priv ? Wireguard.generateKeypair(priv).publicKey : '',
);
}}
/>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={regenerateWireguardKeys}
/>
</Space.Compact>
</Form.Item>
<FormField name="wgPublicKey" label={t('pages.clients.wireguardPublicKey')}>
<FormField
name="wgPublicKey"
label={t('pages.clients.wireguardPublicKey')}
>
<Input disabled />
</FormField>
<FormField name="wgPreSharedKey" label={t('pages.clients.wireguardPreSharedKey')}>
<FormField
name="wgPreSharedKey"
label={t('pages.clients.wireguardPreSharedKey')}
>
<Input />
</FormField>
<FormField
@@ -1032,10 +1183,21 @@ reset: Number(values.reset) || 0,
)}
{showMtproto && (
<>
<Form.Item label={t('pages.clients.mtprotoSecret')} extra={t('pages.clients.mtprotoSecretHint')}>
<Form.Item
label={t('pages.clients.mtprotoSecret')}
extra={t('pages.clients.mtprotoSecretHint')}
>
<Space.Compact style={{ display: 'flex' }}>
<Input value={secret} style={{ flex: 1 }} onChange={(e) => methods.setValue('secret', e.target.value)} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateMtprotoSecret} />
<Input
value={secret}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('secret', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={regenerateMtprotoSecret}
/>
</Space.Compact>
</Form.Item>
<FormField
@@ -1043,10 +1205,7 @@ reset: Number(values.reset) || 0,
label={t('pages.clients.mtprotoAdTag')}
extra={t('pages.clients.mtprotoAdTagHint')}
>
<Input
allowClear
placeholder="0123456789abcdef0123456789abcdef"
/>
<Input allowClear placeholder="0123456789abcdef0123456789abcdef" />
</FormField>
</>
)}
@@ -1062,104 +1221,152 @@ reset: Number(values.reset) || 0,
{t('pages.clients.linksHint')}
</Typography.Paragraph>
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => addExternalLinkRow('link')}
>
{t('pages.clients.addExternalLink')}
</Button>
<div style={{ marginTop: 12, marginBottom: 24 }}>
{linkRows.length === 0 ? (
<Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
) : linkRows.map(({ field, index }) => (
<div key={field.id} className="external-link-card">
<div className="external-link-row">
<div className="external-link-enable">
<FormField name={`externalLinks.${index}.enable`} valueProp="checked" noStyle>
<Switch size="small" />
</FormField>
<span>{t('enable')}</span>
</div>
<FormField name={`externalLinks.${index}.value`} noStyle>
<Input
aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
/>
</FormField>
<Tooltip title={t('delete')}>
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
</Tooltip>
</div>
<div className="external-link-details two-cols">
<FormField name={`externalLinks.${index}.remark`} noStyle>
<Input aria-label={t('remark')} placeholder={t('remark')} />
</FormField>
<Controller
control={methods.control}
name={`externalLinks.${index}.expiryTime`}
render={({ field: expiryField }) => (
<DateTimePicker
value={Number(expiryField.value) > 0 ? dayjs(Number(expiryField.value)) : null}
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
<Typography.Text type="secondary">
{t('pages.clients.noExternalLinks')}
</Typography.Text>
) : (
linkRows.map(({ field, index }) => (
<div key={field.id} className="external-link-card">
<div className="external-link-row">
<div className="external-link-enable">
<FormField
name={`externalLinks.${index}.enable`}
valueProp="checked"
noStyle
>
<Switch size="small" />
</FormField>
<span>{t('enable')}</span>
</div>
<FormField name={`externalLinks.${index}.value`} noStyle>
<Input
aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
/>
)}
/>
</FormField>
<Tooltip title={t('delete')}>
<Button
aria-label={t('delete')}
danger
icon={<DeleteOutlined />}
onClick={() => removeExternalLink(index)}
/>
</Tooltip>
</div>
<div className="external-link-details two-cols">
<FormField name={`externalLinks.${index}.remark`} noStyle>
<Input aria-label={t('remark')} placeholder={t('remark')} />
</FormField>
<Controller
control={methods.control}
name={`externalLinks.${index}.expiryTime`}
render={({ field: expiryField }) => (
<DateTimePicker
value={
Number(expiryField.value) > 0
? dayjs(Number(expiryField.value))
: null
}
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
/>
)}
/>
</div>
</div>
</div>
))}
))
)}
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => addExternalLinkRow('subscription')}
>
{t('pages.clients.addExternalSubscription')}
</Button>
<div style={{ marginTop: 12 }}>
{subscriptionRows.length === 0 ? (
<Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
) : subscriptionRows.map(({ field, index }) => (
<div key={field.id} className="external-link-card">
<div className="external-link-row">
<div className="external-link-enable">
<FormField name={`externalLinks.${index}.enable`} valueProp="checked" noStyle>
<Switch size="small" />
</FormField>
<span>{t('enable')}</span>
</div>
<FormField name={`externalLinks.${index}.value`} noStyle>
<Input
aria-label="https://provider.example/sub/…"
placeholder="https://provider.example/sub/…"
/>
</FormField>
<Tooltip title={t('delete')}>
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
</Tooltip>
</div>
<div className="external-link-details three-cols">
<FormField name={`externalLinks.${index}.remark`} noStyle>
<Input aria-label={t('remark')} placeholder={t('remark')} />
</FormField>
<FormField name={`externalLinks.${index}.namePrefix`} noStyle>
<Input aria-label={t('pages.clients.namePrefix')} placeholder={t('pages.clients.namePrefix')} />
</FormField>
<Controller
control={methods.control}
name={`externalLinks.${index}.expiryTime`}
render={({ field: expiryField }) => (
<DateTimePicker
value={Number(expiryField.value) > 0 ? dayjs(Number(expiryField.value)) : null}
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
<Typography.Text type="secondary">
{t('pages.clients.noExternalSubscriptions')}
</Typography.Text>
) : (
subscriptionRows.map(({ field, index }) => (
<div key={field.id} className="external-link-card">
<div className="external-link-row">
<div className="external-link-enable">
<FormField
name={`externalLinks.${index}.enable`}
valueProp="checked"
noStyle
>
<Switch size="small" />
</FormField>
<span>{t('enable')}</span>
</div>
<FormField name={`externalLinks.${index}.value`} noStyle>
<Input
aria-label="https://provider.example/sub/…"
placeholder="https://provider.example/sub/…"
/>
)}
/>
</FormField>
<Tooltip title={t('delete')}>
<Button
aria-label={t('delete')}
danger
icon={<DeleteOutlined />}
onClick={() => removeExternalLink(index)}
/>
</Tooltip>
</div>
<div className="external-link-details three-cols">
<FormField name={`externalLinks.${index}.remark`} noStyle>
<Input aria-label={t('remark')} placeholder={t('remark')} />
</FormField>
<FormField name={`externalLinks.${index}.namePrefix`} noStyle>
<Input
aria-label={t('pages.clients.namePrefix')}
placeholder={t('pages.clients.namePrefix')}
/>
</FormField>
<Controller
control={methods.control}
name={`externalLinks.${index}.expiryTime`}
render={({ field: expiryField }) => (
<DateTimePicker
value={
Number(expiryField.value) > 0
? dayjs(Number(expiryField.value))
: null
}
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
/>
)}
/>
</div>
<Typography.Text
type={field.lastFetchError ? 'danger' : 'secondary'}
className="external-link-fetch-status"
>
{field.lastFetchError
? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}`
: field.lastFetchAt > 0
? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}`
: t('pages.clients.neverFetched')}
</Typography.Text>
</div>
<Typography.Text type={field.lastFetchError ? 'danger' : 'secondary'} className="external-link-fetch-status">
{field.lastFetchError
? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}`
: field.lastFetchAt > 0
? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}`
: t('pages.clients.neverFetched')}
</Typography.Text>
</div>
))}
))
)}
</div>
</>
),
@@ -1180,7 +1387,13 @@ reset: Number(values.reset) || 0,
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
{t('refresh')}
</Button>,
<Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
<Button
key="clear"
danger
loading={ipsClearing}
disabled={clientIps.length === 0}
onClick={clearIps}
>
{t('pages.clients.clearAll')}
</Button>,
<Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
@@ -1203,9 +1416,12 @@ reset: Number(values.reset) || 0,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
{entry.ip}
{entry.time ? ` (${entry.time})` : ''}
{entry.node ? (
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>
@ {entry.node}
</span>
) : null}
</Tag>
))}
@@ -1222,10 +1438,21 @@ reset: Number(values.reset) || 0,
zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
onCancel={() => setHwidsModalOpen(false)}
footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={hwidsLoading} onClick={loadHwids}>
<Button
key="refresh"
icon={<ReloadOutlined />}
loading={hwidsLoading}
onClick={loadHwids}
>
{t('refresh')}
</Button>,
<Button key="clear" danger loading={hwidsClearing} disabled={clientHwids.length === 0} onClick={clearHwids}>
<Button
key="clear"
danger
loading={hwidsClearing}
disabled={clientHwids.length === 0}
onClick={clearHwids}
>
{t('pages.clients.clearAll')}
</Button>,
<Button key="close" type="primary" onClick={() => setHwidsModalOpen(false)}>
@@ -1236,24 +1463,36 @@ reset: Number(values.reset) || 0,
{clientHwids.length > 0 ? (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{clientHwids.map((entry) => (
<div key={entry.id} style={{ borderBottom: '1px solid var(--ant-color-border-secondary)', padding: '8px 0' }}>
<Typography.Text strong>{entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')}</Typography.Text>
<div
key={entry.id}
style={{
borderBottom: '1px solid var(--ant-color-border-secondary)',
padding: '8px 0',
}}
>
<Typography.Text strong>
{entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')}
</Typography.Text>
<br />
<Typography.Text type="secondary">
{[entry.deviceOs, entry.osVersion].filter(Boolean).join(' ')}
</Typography.Text>
<br />
<Typography.Text type="secondary">
{t('pages.clients.firstSeen')}: {entry.firstSeen ? dayjs(entry.firstSeen).format('YYYY-MM-DD HH:mm') : '-'}
{t('pages.clients.firstSeen')}:{' '}
{entry.firstSeen ? dayjs(entry.firstSeen).format('YYYY-MM-DD HH:mm') : '-'}
</Typography.Text>
<br />
<Typography.Text type="secondary">
{t('pages.clients.lastSeen')}: {entry.lastSeen ? dayjs(entry.lastSeen).format('YYYY-MM-DD HH:mm') : '-'}
{t('pages.clients.lastSeen')}:{' '}
{entry.lastSeen ? dayjs(entry.lastSeen).format('YYYY-MM-DD HH:mm') : '-'}
</Typography.Text>
{entry.userAgent && (
<>
<br />
<Typography.Text type="secondary" style={{ wordBreak: 'break-all' }}>{entry.userAgent}</Typography.Text>
<Typography.Text type="secondary" style={{ wordBreak: 'break-all' }}>
{entry.userAgent}
</Typography.Text>
</>
)}
</div>
@@ -95,7 +95,9 @@
color: var(--ant-color-primary);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--ant-color-primary) 40%, transparent);
transition: background 120ms ease, text-decoration-color 120ms ease;
transition:
background 120ms ease,
text-decoration-color 120ms ease;
}
.link-panel-anchor:hover {
+249 -63
View File
@@ -1,7 +1,13 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, DownloadOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
import {
CopyOutlined,
DownloadOutlined,
EyeOutlined,
QrcodeOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
@@ -12,7 +18,11 @@ import { isPostQuantumLink } from '@/lib/xray/inbound-link';
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
import { QrPanel } from '@/pages/inbounds/qr';
import ConfigBlock from '@/components/clients/ConfigBlock';
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
import {
buildWireguardClientConfig,
findWireguardInbound,
isWireguardClient,
} from './wireguardConfig';
import './ClientInfoModal.css';
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -95,7 +105,9 @@ export default function ClientInfoModal({
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
const [ipsModalOpen, setIpsModalOpen] = useState(false);
const [downloadingFormat, setDownloadingFormat] = useState<keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null>(null);
const [downloadingFormat, setDownloadingFormat] = useState<
keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null
>(null);
useEffect(() => {
if (!open) {
@@ -107,13 +119,15 @@ export default function ClientInfoModal({
if (!client?.subId) return;
let cancelled = false;
(async () => {
const msg = await HttpUtil.get(
const msg = (await HttpUtil.get(
`/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
) as ApiMsg<string[]>;
)) as ApiMsg<string[]>;
if (cancelled) return;
setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [open, client?.subId]);
const traffic = client?.traffic || null;
@@ -143,10 +157,18 @@ export default function ClientInfoModal({
}, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
const showSubscription = !!(subSettings?.enable && client?.subId);
const wgInbound = useMemo(() => findWireguardInbound(client, inboundsById), [client, inboundsById]);
const wgInbound = useMemo(
() => findWireguardInbound(client, inboundsById),
[client, inboundsById],
);
const wgConfigText = useMemo(() => {
if (!client || !wgInbound || !isWireguardClient(client)) return '';
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
return buildWireguardClientConfig(
client,
wgInbound,
window.location.hostname,
subSettings?.publicHost ?? '',
);
}, [client, wgInbound, subSettings?.publicHost]);
async function copyValue(text: string) {
@@ -155,7 +177,10 @@ export default function ClientInfoModal({
if (ok) messageApi.success(t('copied'));
}
async function downloadSubscription(url: string, format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES) {
async function downloadSubscription(
url: string,
format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES,
) {
if (!url || downloadingFormat) return;
setDownloadingFormat(format);
try {
@@ -174,8 +199,13 @@ export default function ClientInfoModal({
if (!client?.email) return;
setIpsLoading(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
if (!msg?.success) { setClientIps([]); return; }
const msg = (await HttpUtil.post(
`/panel/api/clients/ips/${encodeURIComponent(client.email)}`,
)) as ApiMsg<unknown[]>;
if (!msg?.success) {
setClientIps([]);
return;
}
setClientIps(normalizeClientIps(msg.obj));
} finally {
setIpsLoading(false);
@@ -186,7 +216,9 @@ export default function ClientInfoModal({
if (!client?.email) return;
setIpsClearing(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg;
const msg = (await HttpUtil.post(
`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`,
)) as ApiMsg;
if (msg?.success) setClientIps([]);
} finally {
setIpsClearing(false);
@@ -203,7 +235,11 @@ export default function ClientInfoModal({
{messageContextHolder}
<Modal
open={open}
title={client ? `${t('pages.clients.clientInfo')}${client.email}` : t('pages.clients.clientInfo')}
title={
client
? `${t('pages.clients.clientInfo')}${client.email}`
: t('pages.clients.clientInfo')
}
footer={null}
width={640}
onCancel={() => onOpenChange(false)}
@@ -215,12 +251,15 @@ export default function ClientInfoModal({
<tr>
<td>{t('pages.clients.online')}</td>
<td>
{client.enable && isOnline
? <Tag color="green">{t('pages.clients.online')}</Tag>
: <Tag>{t('pages.clients.offline')}</Tag>}
{client.enable && isOnline ? (
<Tag color="green">{t('pages.clients.online')}</Tag>
) : (
<Tag>{t('pages.clients.offline')}</Tag>
)}
<span className="hint">
{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}
{' · '}{t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)}
{' · '}
{t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)}
</span>
</td>
</tr>
@@ -235,9 +274,11 @@ export default function ClientInfoModal({
<tr>
<td>{t('pages.clients.email')}</td>
<td>
{client.email
? <Tag color="green">{client.email}</Tag>
: <Tag color="red">{t('none')}</Tag>}
{client.email ? (
<Tag color="green">{client.email}</Tag>
) : (
<Tag color="red">{t('none')}</Tag>
)}
</td>
</tr>
<tr>
@@ -245,7 +286,13 @@ export default function ClientInfoModal({
<td>
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
{client.subId && (
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.subId!)} />
<Button
size="small"
type="text"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(client.subId!)}
/>
)}
</td>
</tr>
@@ -254,7 +301,13 @@ export default function ClientInfoModal({
<td>{t('pages.clients.uuid')}</td>
<td>
<Tag className="info-large-tag">{client.uuid}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.uuid!)} />
<Button
size="small"
type="text"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(client.uuid!)}
/>
</td>
</tr>
)}
@@ -263,7 +316,13 @@ export default function ClientInfoModal({
<td>{t('password')}</td>
<td>
<Tag className="info-large-tag">{client.password}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.password!)} />
<Button
size="small"
type="text"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(client.password!)}
/>
</td>
</tr>
)}
@@ -272,7 +331,13 @@ export default function ClientInfoModal({
<td>{t('pages.clients.auth')}</td>
<td>
<Tag className="info-large-tag">{client.auth}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.auth!)} />
<Button
size="small"
type="text"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(client.auth!)}
/>
</td>
</tr>
)}
@@ -286,28 +351,37 @@ export default function ClientInfoModal({
<td>{t('pages.inbounds.traffic')}</td>
<td>
<Tag>
{SizeFormatter.sizeFormat(traffic?.up || 0)}
{' '}/ {SizeFormatter.sizeFormat(traffic?.down || 0)}
{SizeFormatter.sizeFormat(traffic?.up || 0)} / {' '}
{SizeFormatter.sizeFormat(traffic?.down || 0)}
</Tag>
<span className="hint">
{SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : ''}
{SizeFormatter.sizeFormat(used)} /{' '}
{totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
</span>
</td>
</tr>
<tr>
<td>{t('remained')}</td>
<td>
{remaining < 0
? <Tag color="purple"></Tag>
: <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
{remaining < 0 ? (
<Tag color="purple"></Tag>
) : (
<Tag color={remaining > 0 ? '' : 'red'}>
{SizeFormatter.sizeFormat(remaining)}
</Tag>
)}
</td>
</tr>
<tr>
<td>{t('pages.inbounds.expireDate')}</td>
<td>
{!client.expiryTime
? <Tag color="purple"></Tag>
: <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>{expiryLabel(client.expiryTime)}</Tag>}
{!client.expiryTime ? (
<Tag color="purple"></Tag>
) : (
<Tag color={client.expiryTime < 0 ? 'blue' : undefined}>
{expiryLabel(client.expiryTime)}
</Tag>
)}
{(client.expiryTime ?? 0) > 0 && (
<span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
)}
@@ -320,7 +394,13 @@ export default function ClientInfoModal({
<tr>
<td>{t('pages.inbounds.IPLimitlog')}</td>
<td>
<Button size="small" icon={<EyeOutlined />} aria-label={t('pages.clients.ipLog')} loading={ipsLoading} onClick={openIpsModal}>
<Button
size="small"
icon={<EyeOutlined />}
aria-label={t('pages.clients.ipLog')}
loading={ipsLoading}
onClick={openIpsModal}
>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</td>
@@ -329,7 +409,11 @@ export default function ClientInfoModal({
<tr>
<td>{t('pages.clients.renewsUsed')}</td>
<td>
<Tag color={(traffic?.resetCount ?? 0) >= (traffic?.resetMax ?? 0) ? 'red' : 'blue'}>
<Tag
color={
(traffic?.resetCount ?? 0) >= (traffic?.resetMax ?? 0) ? 'red' : 'blue'
}
>
{traffic?.resetCount ?? 0} / {traffic?.resetMax}
</Tag>
</td>
@@ -337,22 +421,30 @@ export default function ClientInfoModal({
)}
<tr>
<td>{t('pages.inbounds.createdAt')}</td>
<td><Tag>{dateLabel(client.createdAt)}</Tag></td>
<td>
<Tag>{dateLabel(client.createdAt)}</Tag>
</td>
</tr>
<tr>
<td>{t('pages.inbounds.updatedAt')}</td>
<td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
<td>
<Tag>{dateLabel(client.updatedAt)}</Tag>
</td>
</tr>
{client.group && (
<tr>
<td>{t('pages.clients.group')}</td>
<td><Tag color="geekblue">{client.group}</Tag></td>
<td>
<Tag color="geekblue">{client.group}</Tag>
</td>
</tr>
)}
{client.comment && (
<tr>
<td>{t('pages.clients.comment')}</td>
<td><Tag className="info-large-tag">{client.comment}</Tag></td>
<td>
<Tag className="info-large-tag">{client.comment}</Tag>
</td>
</tr>
)}
<tr>
@@ -404,7 +496,9 @@ export default function ClientInfoModal({
<>
<Divider>{t('subscription.title')}</Divider>
<div className="link-row">
<Tag color="green" className="link-row-tag">SUB</Tag>
<Tag color="green" className="link-row-tag">
SUB
</Tag>
<a
href={subLink}
target="_blank"
@@ -416,26 +510,50 @@ export default function ClientInfoModal({
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(subLink)}
/>
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'standard'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subLink, 'standard')} />
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
loading={downloadingFormat === 'standard'}
disabled={downloadingFormat !== null}
onClick={() => void downloadSubscription(subLink, 'standard')}
/>
</Tooltip>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={<QrPanel value={subLink} remark={`${client.email}${t('subscription.title')}`} size={220} />}
content={
<QrPanel
value={subLink}
remark={`${client.email}${t('subscription.title')}`}
size={220}
/>
}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
</div>
</div>
{subJsonLink && (
<div className="link-row">
<Tag color="purple" className="link-row-tag">JSON</Tag>
<Tag color="purple" className="link-row-tag">
JSON
</Tag>
<a
href={subJsonLink}
target="_blank"
@@ -447,19 +565,41 @@ export default function ClientInfoModal({
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(subJsonLink)}
/>
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'json'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subJsonLink, 'json')} />
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
loading={downloadingFormat === 'json'}
disabled={downloadingFormat !== null}
onClick={() => void downloadSubscription(subJsonLink, 'json')}
/>
</Tooltip>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />}
content={
<QrPanel
value={subJsonLink}
remark={`${client.email} — JSON`}
size={220}
/>
}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
</div>
@@ -468,7 +608,9 @@ export default function ClientInfoModal({
{subClashLink && (
<div className="link-row">
<Tooltip title="Clash / Mihomo">
<Tag color="gold" className="link-row-tag">CLASH</Tag>
<Tag color="gold" className="link-row-tag">
CLASH
</Tag>
</Tooltip>
<a
href={subClashLink}
@@ -481,19 +623,41 @@ export default function ClientInfoModal({
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(subClashLink)}
/>
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'clash'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subClashLink, 'clash')} />
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
loading={downloadingFormat === 'clash'}
disabled={downloadingFormat !== null}
onClick={() => void downloadSubscription(subClashLink, 'clash')}
/>
</Tooltip>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />}
content={
<QrPanel
value={subClashLink}
remark={`${client.email} — Clash / Mihomo`}
size={220}
/>
}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
</div>
@@ -513,13 +677,22 @@ export default function ClientInfoModal({
const canQr = !isPostQuantumLink(link);
return (
<div key={idx} className="link-row">
{parts
? <LinkTags parts={parts} />
: <Tag className="link-row-tag">LINK</Tag>}
<span className="link-row-title" title={rowTitle}>{rowTitle}</span>
{parts ? (
<LinkTags parts={parts} />
) : (
<Tag className="link-row-tag">LINK</Tag>
)}
<span className="link-row-title" title={rowTitle}>
{rowTitle}
</span>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(link)} />
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(link)}
/>
</Tooltip>
{canQr && (
<Popover
@@ -529,7 +702,11 @@ export default function ClientInfoModal({
content={<QrPanel value={link} remark={qrRemark} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
)}
@@ -564,7 +741,13 @@ export default function ClientInfoModal({
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
{t('refresh')}
</Button>,
<Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
<Button
key="clear"
danger
loading={ipsClearing}
disabled={clientIps.length === 0}
onClick={clearIps}
>
{t('pages.clients.clearAll')}
</Button>,
<Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
@@ -587,9 +770,12 @@ export default function ClientInfoModal({
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
{entry.ip}
{entry.time ? ` (${entry.time})` : ''}
{entry.node ? (
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>
@ {entry.node}
</span>
) : null}
</Tag>
))}
+47 -13
View File
@@ -6,7 +6,11 @@ import { isPostQuantumLink } from '@/lib/xray/inbound-link';
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
import { QrPanel } from '@/pages/inbounds/qr';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
import {
buildWireguardClientConfig,
findWireguardInbound,
isWireguardClient,
} from './wireguardConfig';
interface SubSettings {
enable: boolean;
@@ -29,7 +33,13 @@ interface ApiMsg<T = unknown> {
obj?: T;
}
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, publicHost: '' };
const DEFAULT_SUB: SubSettings = {
enable: false,
subURI: '',
subJsonURI: '',
subJsonEnable: false,
publicHost: '',
};
export default function ClientQrModal({
open,
@@ -53,10 +63,18 @@ export default function ClientQrModal({
return subSettings.subJsonURI + client.subId;
}, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
const wgInbound = useMemo(() => findWireguardInbound(client, inboundsById), [client, inboundsById]);
const wgInbound = useMemo(
() => findWireguardInbound(client, inboundsById),
[client, inboundsById],
);
const wgConfigText = useMemo(() => {
if (!client || !wgInbound || !isWireguardClient(client)) return '';
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
return buildWireguardClientConfig(
client,
wgInbound,
window.location.hostname,
subSettings?.publicHost ?? '',
);
}, [client, wgInbound, subSettings?.publicHost]);
const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0;
@@ -70,9 +88,9 @@ export default function ClientQrModal({
setLoading(true);
(async () => {
try {
const msg = await HttpUtil.get(
const msg = (await HttpUtil.get(
`/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
) as ApiMsg<string[]>;
)) as ApiMsg<string[]>;
if (!cancelled) {
setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
}
@@ -80,7 +98,9 @@ export default function ClientQrModal({
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [open, client?.subId]);
const [activeKey, setActiveKey] = useState<string[]>([]);
@@ -91,7 +111,9 @@ export default function ClientQrModal({
out.push({
key: 'sub',
label: t('subscription.title'),
children: <QrPanel value={subLink} remark={`${client?.email || ''}${t('subscription.title')}`} />,
children: (
<QrPanel value={subLink} remark={`${client?.email || ''}${t('subscription.title')}`} />
),
});
}
if (subJsonLink) {
@@ -109,7 +131,9 @@ export default function ClientQrModal({
<LinkTags parts={parts} />
{meta && <span style={{ opacity: 0.6, fontSize: 12 }}>({meta})</span>}
</span>
) : `${t('pages.clients.link')} ${idx + 1}`;
) : (
`${t('pages.clients.link')} ${idx + 1}`
);
out.push({
key: `l${idx}`,
label,
@@ -125,7 +149,11 @@ export default function ClientQrModal({
if (wgConfigText) {
out.push({
key: 'wg-config',
label: <Tag color="cyan" style={{ margin: 0 }}>{t('pages.clients.wireguardConfig')}</Tag>,
label: (
<Tag color="cyan" style={{ margin: 0 }}>
{t('pages.clients.wireguardConfig')}
</Tag>
),
children: (
<QrPanel
value={wgConfigText}
@@ -157,15 +185,21 @@ export default function ClientQrModal({
>
<Spin spinning={loading}>
{!client?.subId && !loading && (
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>{t('pages.clients.noSubId')}</div>
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
{t('pages.clients.noSubId')}
</div>
)}
{client?.subId && !hasAnything && !loading && (
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>{t('pages.clients.noLinks')}</div>
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
{t('pages.clients.noLinks')}
</div>
)}
{hasAnything && (
<Collapse
activeKey={activeKey}
onChange={(keys) => setActiveKey(typeof keys === 'string' ? [keys] : (keys as string[]))}
onChange={(keys) =>
setActiveKey(typeof keys === 'string' ? [keys] : (keys as string[]))
}
items={items}
/>
)}
+15 -5
View File
@@ -70,11 +70,21 @@
vertical-align: middle;
}
.dot-green { background: var(--ant-color-success); }
.dot-blue { background: var(--ant-color-primary); }
.dot-red { background: var(--ant-color-error); }
.dot-orange { background: var(--ant-color-warning); }
.dot-gray { background: var(--ant-color-text-quaternary); }
.dot-green {
background: var(--ant-color-success);
}
.dot-blue {
background: var(--ant-color-primary);
}
.dot-red {
background: var(--ant-color-error);
}
.dot-orange {
background: var(--ant-color-warning);
}
.dot-gray {
background: var(--ant-color-text-quaternary);
}
.status-tag {
margin: 0 0 0 4px;
File diff suppressed because it is too large Load Diff
+19 -18
View File
@@ -52,10 +52,11 @@ export default function FilterDrawer({
}
const inboundOptions = useMemo(
() => inbounds.map((ib) => ({
value: ib.id,
label: formatInboundLabel(ib.tag, ib.remark),
})),
() =>
inbounds.map((ib) => ({
value: ib.id,
label: formatInboundLabel(ib.tag, ib.remark),
})),
[inbounds],
);
@@ -64,10 +65,7 @@ export default function FilterDrawer({
[protocols],
);
const groupOptions = useMemo(
() => groups.map((g) => ({ value: g, label: g })),
[groups],
);
const groupOptions = useMemo(() => groups.map((g) => ({ value: g, label: g })), [groups]);
// 0 is the "local panel" sentinel (inbounds without a nodeId) — see
// ClientFilters.nodeIds (#4997).
@@ -104,10 +102,7 @@ export default function FilterDrawer({
>
<Form layout="vertical">
<Form.Item label={<Typography.Text strong>{t('status')}</Typography.Text>}>
<Checkbox.Group
value={filters.buckets}
onChange={(v) => patch('buckets', v as string[])}
>
<Checkbox.Group value={filters.buckets} onChange={(v) => patch('buckets', v as string[])}>
<Space orientation="vertical">
{BUCKET_KEYS.map((k) => (
<Checkbox key={k} value={k}>
@@ -260,11 +255,17 @@ export default function FilterDrawer({
function bucketLabel(key: string, t: (k: string) => string): string {
switch (key) {
case 'active': return t('subscription.active');
case 'expiring': return t('depletingSoon');
case 'depleted': return t('depleted');
case 'deactive': return t('disabled');
case 'online': return t('online');
default: return key;
case 'active':
return t('subscription.active');
case 'expiring':
return t('depletingSoon');
case 'depleted':
return t('depleted');
case 'deactive':
return t('disabled');
case 'online':
return t('online');
default:
return key;
}
}
+6 -2
View File
@@ -130,7 +130,9 @@ export const ClientInboundChips = memo(function ClientInboundChips({
const proto = (inboundsById[id]?.protocol || '').toLowerCase();
return (
<Tooltip key={id} title={label(id)}>
<Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>{label(id)}</Tag>
<Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>
{label(id)}
</Tag>
</Tooltip>
);
};
@@ -146,7 +148,9 @@ export const ClientInboundChips = memo(function ClientInboundChips({
placement="bottomRight"
content={<div style={OVERFLOW_LIST_STYLE}>{overflow.map(chip)}</div>}
>
<Tag color="default" style={OVERFLOW_CHIP_STYLE}>+{overflow.length}</Tag>
<Tag color="default" style={OVERFLOW_CHIP_STYLE}>
+{overflow.length}
</Tag>
</Popover>
)}
</>
+22 -5
View File
@@ -61,7 +61,12 @@ export default function SubLinksModal({
}, [emails, clients, enabled, jsonEnabled, subSettings]);
const allText = useMemo(
() => rows.map((r) => (jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`)).join('\n'),
() =>
rows
.map((r) =>
jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`,
)
.join('\n'),
[rows, jsonEnabled],
);
@@ -102,7 +107,9 @@ export default function SubLinksModal({
ellipsis: true,
render: (link: string) => (
<Tooltip title={link} placement="topLeft">
<Typography.Text copyable={false} ellipsis>{link}</Typography.Text>
<Typography.Text copyable={false} ellipsis>
{link}
</Typography.Text>
</Tooltip>
),
},
@@ -111,7 +118,13 @@ export default function SubLinksModal({
key: 'actions',
width: 64,
render: (_v, row) => (
<Button size="small" type="text" aria-label={t('copy')} icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
<Button
size="small"
type="text"
aria-label={t('copy')}
icon={<CopyOutlined />}
onClick={() => copy(row.link, t('copied'))}
/>
),
},
];
@@ -124,7 +137,9 @@ export default function SubLinksModal({
ellipsis: true,
render: (link: string) => (
<Tooltip title={link} placement="topLeft">
<Typography.Text copyable={false} ellipsis>{link}</Typography.Text>
<Typography.Text copyable={false} ellipsis>
{link}
</Typography.Text>
</Tooltip>
),
});
@@ -144,7 +159,9 @@ export default function SubLinksModal({
<Button
icon={<CopyOutlined />}
disabled={rows.length === 0}
onClick={() => copy(allText, t('pages.clients.subLinksCopiedAll', { count: rows.length }))}
onClick={() =>
copy(allText, t('pages.clients.subLinksCopiedAll', { count: rows.length }))
}
>
{t('pages.clients.subLinksCopyAll')}
</Button>
+14 -3
View File
@@ -4,7 +4,13 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
export function isWireguardClient(client: ClientRecord | null | undefined): boolean {
if (!client) return false;
return !!(client.privateKey || client.publicKey || client.allowedIPs || client.preSharedKey || client.keepAlive);
return !!(
client.privateKey ||
client.publicKey ||
client.allowedIPs ||
client.preSharedKey ||
client.keepAlive
);
}
export function findWireguardInbound(
@@ -22,7 +28,11 @@ export function buildWireguardClientConfig(
host = window.location.hostname,
publicHost = '',
): string {
const endpointHost = resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost));
const endpointHost = resolveShareHost(
inbound ?? {},
inbound?.nodeAddress ?? '',
preferPublicHost(host, publicHost),
);
const address = client.allowedIPs || '10.0.0.2/32';
const endpoint = `${endpointHost}:${inbound?.port || ''}`;
const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
@@ -39,6 +49,7 @@ export function buildWireguardClientConfig(
lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`);
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
if (client.keepAlive && client.keepAlive > 0) lines.push(`PersistentKeepalive = ${client.keepAlive}`);
if (client.keepAlive && client.keepAlive > 0)
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
return lines.join('\n');
}
@@ -74,7 +74,11 @@ export default function GroupAddClientsModal({
width: 140,
ellipsis: true,
render: (g: string) =>
g ? <Tag color="geekblue">{g}</Tag> : <span style={{ color: 'rgba(0,0,0,0.45)' }}></span>,
g ? (
<Tag color="geekblue">{g}</Tag>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)' }}></span>
),
},
{
title: t('enable'),
+65 -24
View File
@@ -57,7 +57,10 @@ import {
} from '@/schemas/client';
import { parseMsg } from '@/utils/zodValidate';
const ClientRecordListSchema = z.array(ClientRecordSchema).nullable().transform((v) => v ?? []);
const ClientRecordListSchema = z
.array(ClientRecordSchema)
.nullable()
.transform((v) => v ?? []);
const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
@@ -90,10 +93,14 @@ export default function GroupsPage() {
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const queryClient = useQueryClient();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false });
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({
list: false,
});
const groupsQuery = useQuery({
queryKey: keys.clients.groups(),
@@ -111,25 +118,33 @@ export default function GroupsPage() {
const createMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const renameMut = useMutation({
mutationFn: (body: { oldName: string; newName: string }) =>
HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const deleteMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const groupResetMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const [createOpen, setCreateOpen] = useState(false);
@@ -168,14 +183,8 @@ export default function GroupsPage() {
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
[groups],
);
const totalUpload = useMemo(
() => groups.reduce((acc, g) => acc + (g.up || 0), 0),
[groups],
);
const totalDownload = useMemo(
() => groups.reduce((acc, g) => acc + (g.down || 0), 0),
[groups],
);
const totalUpload = useMemo(() => groups.reduce((acc, g) => acc + (g.up || 0), 0), [groups]);
const totalDownload = useMemo(() => groups.reduce((acc, g) => acc + (g.down || 0), 0), [groups]);
function openCreate() {
setCreateName('');
@@ -209,7 +218,11 @@ export default function GroupsPage() {
setRenameOpen(false);
return;
}
if (groups.some((g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name)) {
if (
groups.some(
(g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name,
)
) {
messageApi.error(t('pages.groups.renameCollision', { name: next }));
return;
}
@@ -305,9 +318,11 @@ export default function GroupsPage() {
messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok }));
} else {
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
messageApi.warning(firstError
? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}`
: t('pages.groups.deleteClientsMixed', { ok, failed }));
messageApi.warning(
firstError
? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}`
: t('pages.groups.deleteClientsMixed', { ok, failed }),
);
}
}
},
@@ -404,10 +419,23 @@ export default function GroupsPage() {
render: (_v, row) => (
<Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button aria-label={t('more')} size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
<Button
aria-label={t('more')}
size="small"
type="text"
style={{ fontSize: 16 }}
icon={<MoreOutlined />}
/>
</Dropdown>
<Tooltip title={t('pages.groups.rename')}>
<Button aria-label={t('pages.groups.rename')} size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
<Button
aria-label={t('pages.groups.rename')}
size="small"
type="text"
style={{ fontSize: 16 }}
icon={<EditOutlined />}
onClick={() => openRename(row)}
/>
</Tooltip>
</Space>
),
@@ -416,7 +444,11 @@ export default function GroupsPage() {
title: t('pages.groups.name'),
dataIndex: 'name',
key: 'name',
render: (name: string) => <Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>{name}</Tag>,
render: (name: string) => (
<Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>
{name}
</Tag>
),
},
{
title: t('pages.groups.clientCount'),
@@ -471,7 +503,11 @@ export default function GroupsPage() {
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={<Button type="primary" loading={loading} onClick={() => groupsQuery.refetch()}>{t('refresh')}</Button>}
extra={
<Button type="primary" loading={loading} onClick={() => groupsQuery.refetch()}>
{t('refresh')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
@@ -522,7 +558,12 @@ export default function GroupsPage() {
hoverable
title={
<div className="card-toolbar">
<Button aria-label={t('pages.groups.addGroup')} type="primary" icon={<PlusOutlined />} onClick={openCreate}>
<Button
aria-label={t('pages.groups.addGroup')}
type="primary"
icon={<PlusOutlined />}
onClick={openCreate}
>
{!isMobile && t('pages.groups.addGroup')}
</Button>
</div>
+173 -41
View File
@@ -66,7 +66,8 @@ function defaultsFor(host: HostRecord | null): FormShape {
sockoptParams: asString(host?.sockoptParams),
finalMask: host?.finalMask ?? '',
vlessRoute: host?.vlessRoute ?? '',
excludeFromSubTypes: (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [],
excludeFromSubTypes:
(host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [],
nodeGuids: host?.nodeGuids ?? [],
mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'],
mihomoX25519: host?.mihomoX25519 ?? false,
@@ -74,7 +75,15 @@ function defaultsFor(host: HostRecord | null): FormShape {
};
}
export default function HostFormModal({ open, mode, host, inboundOptions, existingHosts, save, onOpenChange }: HostFormModalProps) {
export default function HostFormModal({
open,
mode,
host,
inboundOptions,
existingHosts,
save,
onOpenChange,
}: HostFormModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
@@ -95,22 +104,30 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
const { nodes } = useNodesQuery();
const inboundSelectOptions = useMemo(
() => inboundOptions.map((ib) => ({
value: ib.id,
label: ib.remark || ib.tag || `#${ib.id}`,
})),
() =>
inboundOptions.map((ib) => ({
value: ib.id,
label: ib.remark || ib.tag || `#${ib.id}`,
})),
[inboundOptions],
);
const nodeSelectOptions = useMemo(
() => nodes
.filter((n) => n.guid)
.map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
() =>
nodes
.filter((n) => n.guid)
.map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
[nodes],
);
const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
const alpnOptions = useMemo(
() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })),
[],
);
const fpOptions = useMemo(
() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })),
[],
);
const hostOptions = useMemo(() => {
const addresses = new Set<string>();
@@ -139,7 +156,9 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
try {
const res = await save(payload);
if (res?.success) {
messageApi.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
messageApi.success(
t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'),
);
onOpenChange(false);
} else if (res?.msg) {
messageApi.error(res.msg);
@@ -181,13 +200,26 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
children: (
<>
<FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.remark) }}>
<FormField
name="remark"
label={t('pages.hosts.fields.remark')}
tooltip={t('pages.hosts.hints.remark')}
rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.remark) }}
>
<Input maxLength={256} />
</FormField>
<FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
<FormField
name="serverDescription"
label={t('pages.hosts.fields.serverDescription')}
tooltip={t('pages.hosts.hints.serverDescription')}
>
<Input maxLength={64} />
</FormField>
<FormField name="inboundIds" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.inboundIds) }}>
<FormField
name="inboundIds"
label={t('pages.hosts.fields.inbound')}
rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.inboundIds) }}
>
<Select
mode="multiple"
options={inboundSelectOptions}
@@ -195,7 +227,12 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
placeholder={t('pages.hosts.selectInbound')}
/>
</FormField>
<FormField name="hosts" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.hosts) }}>
<FormField
name="hosts"
label={t('pages.hosts.fields.address')}
tooltip={t('pages.hosts.hints.address')}
rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.hosts) }}
>
<Select
mode="tags"
options={hostOptions}
@@ -203,16 +240,37 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
placeholder="cdn.example.com, cdn2.example.com:443"
/>
</FormField>
<FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
<FormField
name="port"
label={t('pages.hosts.fields.port')}
tooltip={t('pages.hosts.hints.port')}
>
<InputNumber min={0} max={65535} />
</FormField>
<FormField name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
<FormField
name="tags"
label={t('pages.hosts.fields.tags')}
tooltip={t('pages.hosts.hints.tags')}
>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField>
<FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
<Select mode="multiple" allowClear options={nodeSelectOptions} showSearch={{ optionFilterProp: 'label' }} />
<FormField
name="nodeGuids"
label={t('pages.hosts.fields.nodeGuids')}
tooltip={t('pages.hosts.hints.nodeGuids')}
>
<Select
mode="multiple"
allowClear
options={nodeSelectOptions}
showSearch={{ optionFilterProp: 'label' }}
/>
</FormField>
<FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
<FormField
name="enable"
label={t('pages.hosts.fields.enable')}
valueProp="checked"
>
<Switch />
</FormField>
</>
@@ -221,12 +279,19 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
{
key: 'security',
forceRender: true,
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
label: catTabLabel(
<SafetyCertificateOutlined />,
t('pages.hosts.sections.security'),
isMobile,
),
children: (
<>
<FormField name="security" label={t('pages.hosts.fields.security')}>
<Select
options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
options={['same', 'tls', 'none', 'reality'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
{showTls && (
@@ -234,10 +299,18 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
<FormField name="sni" label={t('pages.hosts.fields.sni')}>
<Input />
</FormField>
<FormField name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valueProp="checked">
<FormField
name="overrideSniFromAddress"
label={t('pages.hosts.fields.overrideSniFromAddress')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valueProp="checked">
<FormField
name="keepSniBlank"
label={t('pages.hosts.fields.keepSniBlank')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
@@ -253,13 +326,25 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField>
<FormField name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
<FormField
name="verifyPeerCertByName"
label={t('pages.hosts.fields.verifyPeerCertByName')}
tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}
>
<Input placeholder="example.com" />
</FormField>
<FormField name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valueProp="checked">
<FormField
name="allowInsecure"
label={t('pages.hosts.fields.allowInsecure')}
tooltip={t('pages.hosts.hints.allowInsecure')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
<FormField
name="echConfigList"
label={t('pages.hosts.fields.echConfigList')}
>
<Input.TextArea rows={2} />
</FormField>
</>
@@ -270,7 +355,11 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
{
key: 'advanced',
forceRender: true,
label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
label: catTabLabel(
<ControlOutlined />,
t('pages.hosts.sections.advanced'),
isMobile,
),
children: (
<Tabs
size="small"
@@ -279,7 +368,11 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
{
key: 'adv-general',
forceRender: true,
label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
label: catTabLabel(
<SettingOutlined />,
t('pages.hosts.sections.general'),
isMobile,
),
children: (
<>
<FormField name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
@@ -288,14 +381,24 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
<FormField name="path" label={t('pages.hosts.fields.path')}>
<Input />
</FormField>
<FormField name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
<FormField
name="vlessRoute"
label={t('pages.hosts.fields.vlessRoute')}
tooltip={t('pages.hosts.hints.vlessRoute')}
>
<Input placeholder="443" />
</FormField>
<FormField name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
<FormField
name="excludeFromSubTypes"
label={t('pages.hosts.fields.excludeFromSubTypes')}
>
<Select
mode="multiple"
allowClear
options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
options={['raw', 'json', 'clash'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
</>
@@ -304,7 +407,11 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
{
key: 'adv-mux',
forceRender: true,
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
label: catTabLabel(
<PartitionOutlined />,
t('pages.hosts.fields.muxParams'),
isMobile,
),
children: (
<Form.Item noStyle>
<Controller
@@ -320,7 +427,11 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
{
key: 'adv-sockopt',
forceRender: true,
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
label: catTabLabel(
<DeploymentUnitOutlined />,
t('pages.hosts.fields.sockoptParams'),
isMobile,
),
children: (
<Form.Item noStyle>
<Controller
@@ -336,7 +447,11 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
{
key: 'adv-finalmask',
forceRender: true,
label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
label: catTabLabel(
<RocketOutlined />,
t('pages.hosts.fields.finalMask'),
isMobile,
),
children: (
<Form.Item noStyle>
<Controller
@@ -356,19 +471,36 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
{
key: 'clash',
forceRender: true,
label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
label: catTabLabel(
<NodeIndexOutlined />,
t('pages.hosts.sections.clash'),
isMobile,
),
children: (
<>
<FormField name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
<FormField
name="mihomoIpVersion"
label={t('pages.hosts.fields.mihomoIpVersion')}
>
<Select
allowClear
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map(
(v) => ({ value: v, label: v }),
)}
/>
</FormField>
<FormField name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valueProp="checked">
<FormField
name="mihomoX25519"
label={t('pages.hosts.fields.mihomoX25519')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valueProp="checked">
<FormField
name="shuffleHost"
label={t('pages.hosts.fields.shuffleHost')}
valueProp="checked"
>
<Switch />
</FormField>
</>
+92 -18
View File
@@ -56,8 +56,19 @@ export function sortHosts(hosts: HostRecord[]): HostRecord[] {
export default function HostList(props: HostListProps) {
const { t } = useTranslation();
const {
hosts, inboundOptions, loading, isMobile, selectedGroupIds, onSelectionChange,
onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete,
hosts,
inboundOptions,
loading,
isMobile,
selectedGroupIds,
onSelectionChange,
onAdd,
onEdit,
onDelete,
onToggleEnable,
onMove,
onBulkEnable,
onBulkDelete,
} = props;
const inboundsMap = useMemo(() => {
@@ -78,16 +89,43 @@ export default function HostList(props: HostListProps) {
return (
<Space size={2}>
<Tooltip title={t('pages.hosts.moveUp')}>
<Button size="small" type="text" icon={<ArrowUpOutlined />} aria-label={t('pages.hosts.moveUp')} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
<Button
size="small"
type="text"
icon={<ArrowUpOutlined />}
aria-label={t('pages.hosts.moveUp')}
disabled={idx === 0}
onClick={() => onMove(h, 'up')}
/>
</Tooltip>
<Tooltip title={t('pages.hosts.moveDown')}>
<Button size="small" type="text" icon={<ArrowDownOutlined />} aria-label={t('pages.hosts.moveDown')} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
<Button
size="small"
type="text"
icon={<ArrowDownOutlined />}
aria-label={t('pages.hosts.moveDown')}
disabled={idx >= count - 1}
onClick={() => onMove(h, 'down')}
/>
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(h)} />
<Button
size="small"
type="text"
icon={<EditOutlined />}
aria-label={t('edit')}
onClick={() => onEdit(h)}
/>
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(h)} />
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
aria-label={t('delete')}
onClick={() => onDelete(h)}
/>
</Tooltip>
</Space>
);
@@ -116,20 +154,34 @@ export default function HostList(props: HostListProps) {
title: t('pages.hosts.fields.endpoint'),
key: 'endpoint',
render: (_, h) => {
const addrs = h.hosts?.filter(a => a.trim() !== '') || [];
if (addrs.length === 0) return <Tag color="orange">{t('pages.hosts.fields.inheritAddress') || 'inherits'}</Tag>;
const addrs = h.hosts?.filter((a) => a.trim() !== '') || [];
if (addrs.length === 0)
return <Tag color="orange">{t('pages.hosts.fields.inheritAddress') || 'inherits'}</Tag>;
const visible = addrs.slice(0, 1);
const overflow = addrs.slice(1);
return (
<>
{visible.map((addr) => <Tag key={addr}>{addr}</Tag>)}
{visible.map((addr) => (
<Tag key={addr}>{addr}</Tag>
))}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
{overflow.map((addr) => <Tag key={addr}>{addr}</Tag>)}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
maxWidth: 280,
maxHeight: 280,
overflowY: 'auto',
}}
>
{overflow.map((addr) => (
<Tag key={addr}>{addr}</Tag>
))}
</div>
}
>
@@ -152,12 +204,14 @@ export default function HostList(props: HostListProps) {
const overflow = ids.slice(1);
const chip = (id: number) => {
const ib = inboundsMap.get(id);
const label = ib ? (ib.remark || ib.tag || `#${id}`) : `#${id}`;
const label = ib ? ib.remark || ib.tag || `#${id}` : `#${id}`;
const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
return (
<Tooltip key={id} title={label}>
<Tag color={color} style={{ margin: 2 }}>{label}</Tag>
<Tag color={color} style={{ margin: 2 }}>
{label}
</Tag>
</Tooltip>
);
};
@@ -169,7 +223,16 @@ export default function HostList(props: HostListProps) {
trigger="click"
placement="bottomRight"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
maxWidth: 280,
maxHeight: 280,
overflowY: 'auto',
}}
>
{overflow.map(chip)}
</div>
}
@@ -192,9 +255,18 @@ export default function HostList(props: HostListProps) {
{
title: t('pages.hosts.fields.tags'),
key: 'tags',
render: (_, h) => (h.tags && h.tags.length > 0
? <Space size={[0, 4]} wrap>{h.tags.map((tag) => <Tag key={tag} color="blue">{tag}</Tag>)}</Space>
: <span className="host-muted"></span>),
render: (_, h) =>
h.tags && h.tags.length > 0 ? (
<Space size={[0, 4]} wrap>
{h.tags.map((tag) => (
<Tag key={tag} color="blue">
{tag}
</Tag>
))}
</Space>
) : (
<span className="host-muted"></span>
),
},
];
@@ -216,7 +288,9 @@ export default function HostList(props: HostListProps) {
</Tag>
<Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
<Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
<Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>{t('pages.hosts.bulkDelete')}</Button>
<Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>
{t('pages.hosts.bulkDelete')}
</Button>
</>
)}
</div>
+79 -41
View File
@@ -1,6 +1,18 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
import {
Button,
Card,
Col,
ConfigProvider,
Layout,
Modal,
Result,
Row,
Spin,
Statistic,
message,
} from 'antd';
import { CheckCircleOutlined, GlobalOutlined, StopOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
@@ -20,10 +32,13 @@ export default function HostsPage() {
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery();
const { bulkCreate, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations();
const { bulkCreate, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } =
useHostMutations();
const { data: inboundOptions = [] } = useInboundOptions();
const [formOpen, setFormOpen] = useState(false);
@@ -43,45 +58,60 @@ export default function HostsPage() {
setFormOpen(true);
}, []);
const onSave = useCallback(async (payload: BulkAddHostValues) => {
if (formMode === 'edit' && formHost?.groupId) {
return update(formHost.groupId, payload);
}
return bulkCreate(payload);
}, [formMode, formHost, update, bulkCreate]);
const onSave = useCallback(
async (payload: BulkAddHostValues) => {
if (formMode === 'edit' && formHost?.groupId) {
return update(formHost.groupId, payload);
}
return bulkCreate(payload);
},
[formMode, formHost, update, bulkCreate],
);
const onDelete = useCallback((host: HostRecord) => {
modal.confirm({
title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(host.groupId);
if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete'));
},
});
}, [modal, t, remove, messageApi]);
const onDelete = useCallback(
(host: HostRecord) => {
modal.confirm({
title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(host.groupId);
if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete'));
},
});
},
[modal, t, remove, messageApi],
);
const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => {
await setEnable(host.groupId, next);
}, [setEnable]);
const onToggleEnable = useCallback(
async (host: HostRecord, next: boolean) => {
await setEnable(host.groupId, next);
},
[setEnable],
);
const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => {
const sorted = sortHosts(hosts);
const idx = sorted.findIndex((h) => h.groupId === host.groupId);
const swapWith = dir === 'up' ? idx - 1 : idx + 1;
if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return;
const groupIds = sorted.map((h) => h.groupId);
[groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]];
await reorder(groupIds);
}, [hosts, reorder]);
const onMove = useCallback(
async (host: HostRecord, dir: 'up' | 'down') => {
const sorted = sortHosts(hosts);
const idx = sorted.findIndex((h) => h.groupId === host.groupId);
const swapWith = dir === 'up' ? idx - 1 : idx + 1;
if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return;
const groupIds = sorted.map((h) => h.groupId);
[groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]];
await reorder(groupIds);
},
[hosts, reorder],
);
const onBulkEnable = useCallback(async (enable: boolean) => {
if (selectedGroupIds.length === 0) return;
const msg = await bulkSetEnable(selectedGroupIds, enable);
if (msg?.success) setSelectedGroupIds([]);
}, [selectedGroupIds, bulkSetEnable]);
const onBulkEnable = useCallback(
async (enable: boolean) => {
if (selectedGroupIds.length === 0) return;
const msg = await bulkSetEnable(selectedGroupIds, enable);
if (msg?.success) setSelectedGroupIds([]);
},
[selectedGroupIds, bulkSetEnable],
);
const onBulkDelete = useCallback(() => {
if (selectedGroupIds.length === 0) return;
@@ -129,7 +159,11 @@ export default function HostsPage() {
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={<Button type="primary" loading={loading} onClick={() => refetch()}>{t('refresh')}</Button>}
extra={
<Button type="primary" loading={loading} onClick={() => refetch()}>
{t('refresh')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
@@ -147,14 +181,18 @@ export default function HostsPage() {
<Statistic
title={t('pages.hosts.summary.enabled')}
value={String(summary.enabled)}
prefix={<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />}
prefix={
<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />
}
/>
</Col>
<Col xs={8} sm={8} md={8}>
<Statistic
title={t('pages.hosts.summary.disabled')}
value={String(summary.disabled)}
prefix={<StopOutlined style={{ color: 'var(--ant-color-text-quaternary)' }} />}
prefix={
<StopOutlined style={{ color: 'var(--ant-color-text-quaternary)' }} />
}
/>
</Col>
</Row>
@@ -25,7 +25,13 @@ function parseFinalMask(raw: string): FinalMaskStreamSettings {
return { tcp: [], udp: [] };
}
export default function HostFinalMaskForm({ value = '', onChange }: { value?: string; onChange?: (next: string) => void }) {
export default function HostFinalMaskForm({
value = '',
onChange,
}: {
value?: string;
onChange?: (next: string) => void;
}) {
const [form] = Form.useForm();
const [initial] = useState(() => parseFinalMask(value));
const onChangeRef = useRef(onChange);
@@ -9,16 +9,29 @@ import { serializeOverride } from './helpers';
* the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
* when the toggle is off, an explicit mux object when on.
*/
const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
const DEFAULT_MUX = {
enabled: false,
concurrency: 8,
xudpConcurrency: 16,
xudpProxyUDP443: 'reject',
};
export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
export default function HostMuxForm({
value,
onChange,
}: {
value?: string;
onChange?: (next: string) => void;
}) {
return (
<OutboundSubtreeJsonForm
value={value}
onChange={onChange}
path={['mux']}
defaultSubtree={DEFAULT_MUX}
serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
serialize={(mux) =>
(mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : ''
}
/* protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
a host's mux override is protocol-agnostic and should always be editable. */
render={() => <MuxForm protocol="vmess" network="tcp" />}
@@ -25,7 +25,13 @@ function serializeClientSockopt(sockopt: unknown): string {
return serializeOverride(copy);
}
export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
export default function HostSockoptForm({
value,
onChange,
}: {
value?: string;
onChange?: (next: string) => void;
}) {
/*
* Populate the dialerProxy dropdown with the panel's outbound tags (a host can
* chain through one of the subscription's outbounds by tag). dialerProxy chains
@@ -34,21 +34,29 @@ export default function CloneInboundModal({
const [targets, setTargets] = useState<number[]>([LOCAL_PANEL]);
const [submitting, setSubmitting] = useState(false);
const targetOptions = useMemo(() => [
{ value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false },
...(nodes || []).filter((n) => n.enable).map((n) => ({
value: n.id,
// Only online nodes are deployable targets: nodes report `unknown`
// until their first heartbeat, and the backend refuses any status
// other than online.
label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`,
disabled: n.status !== 'online',
})),
], [nodes, t]);
const targetOptions = useMemo(
() => [
{ value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false },
...(nodes || [])
.filter((n) => n.enable)
.map((n) => ({
value: n.id,
// Only online nodes are deployable targets: nodes report `unknown`
// until their first heartbeat, and the backend refuses any status
// other than online.
label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`,
disabled: n.status !== 'online',
})),
],
[nodes, t],
);
// "Select all" must not pick targets the user can't pick manually —
// offline nodes are disabled options in the dropdown.
const selectableOptions = useMemo(() => targetOptions.filter((o) => !o.disabled), [targetOptions]);
const selectableOptions = useMemo(
() => targetOptions.filter((o) => !o.disabled),
[targetOptions],
);
// Reset the selection when the dialog OPENS: pre-select the source
// inbound's own node when it is a selectable target, otherwise the local
@@ -75,17 +83,23 @@ export default function CloneInboundModal({
for (const target of targets) {
const msg = await HttpUtil.post(
'/panel/api/inbounds/add',
buildClonePayload(dbInbound, pickClonePort(portsInUse.get(target)), target === LOCAL_PANEL ? null : target),
buildClonePayload(
dbInbound,
pickClonePort(portsInUse.get(target)),
target === LOCAL_PANEL ? null : target,
),
{ silent: true },
);
results.push({ ok: !!msg?.success, reason: msg?.success ? '' : (msg?.msg || '') });
results.push({ ok: !!msg?.success, reason: msg?.success ? '' : msg?.msg || '' });
}
const okCount = results.filter((r) => r.ok).length;
const failed = results.length - okCount;
if (failed === 0) {
messageApi.success(okCount === 1
? t('pages.inbounds.toasts.inboundCreateSuccess')
: t('pages.inbounds.toasts.clonedMany', { count: okCount }));
messageApi.success(
okCount === 1
? t('pages.inbounds.toasts.inboundCreateSuccess')
: t('pages.inbounds.toasts.clonedMany', { count: okCount }),
);
} else {
const firstError = results.find((r) => !r.ok)?.reason ?? '';
const base = t('pages.inbounds.toasts.clonedMixed', { ok: okCount, failed });
@@ -114,11 +128,7 @@ export default function CloneInboundModal({
<Typography.Paragraph type="secondary">
{t('pages.inbounds.cloneConfirmContent')}
</Typography.Paragraph>
<SelectAllClearButtons
options={selectableOptions}
value={targets}
onChange={setTargets}
/>
<SelectAllClearButtons options={selectableOptions} value={targets} onChange={setTargets} />
<Select
aria-label={t('pages.inbounds.deployTo')}
mode="multiple"
+427 -293
View File
@@ -101,7 +101,9 @@ export default function InboundsPage() {
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
const nodesById = useMemo(() => {
@@ -177,94 +179,129 @@ export default function InboundsPage() {
const [promptInitial, setPromptInitial] = useState('');
const [promptJson, setPromptJson] = useState(false);
const [promptLoading, setPromptLoading] = useState(false);
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
const [promptHandler, setPromptHandler] = useState<
((value: string) => Promise<boolean | void> | boolean | void) | null
>(null);
const hostOverrideFor = useCallback((dbInbound: DBInbound | null) => {
if (!dbInbound || dbInbound.nodeId == null) return '';
return nodesById.get(dbInbound.nodeId)?.address || '';
}, [nodesById]);
const hostOverrideFor = useCallback(
(dbInbound: DBInbound | null) => {
if (!dbInbound || dbInbound.nodeId == null) return '';
return nodesById.get(dbInbound.nodeId)?.address || '';
},
[nodesById],
);
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
const infoNodeAddress = useMemo(
() => hostOverrideFor(infoDbInbound),
[infoDbInbound, hostOverrideFor],
);
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean; tabs?: TextModalTab[] }) => {
setTextTitle(opts.title);
setTextContent(opts.content);
setTextFileName(opts.fileName || '');
setTextJson(opts.json || false);
setTextTabs(opts.tabs);
setTextOpen(true);
}, []);
const openText = useCallback(
(opts: {
title: string;
content: string;
fileName?: string;
json?: boolean;
tabs?: TextModalTab[];
}) => {
setTextTitle(opts.title);
setTextContent(opts.content);
setTextFileName(opts.fileName || '');
setTextJson(opts.json || false);
setTextTabs(opts.tabs);
setTextOpen(true);
},
[],
);
const openPrompt = useCallback((opts: {
title: string;
okText?: string;
type?: 'textarea' | 'input';
value?: string;
json?: boolean;
confirm: (value: string) => Promise<boolean | void> | boolean | void;
}) => {
setPromptTitle(opts.title);
setPromptOkText(opts.okText || t('confirm'));
setPromptType(opts.type || 'textarea');
setPromptInitial(opts.value || '');
setPromptJson(opts.json || false);
setPromptHandler(() => opts.confirm);
setPromptOpen(true);
}, [t]);
const openPrompt = useCallback(
(opts: {
title: string;
okText?: string;
type?: 'textarea' | 'input';
value?: string;
json?: boolean;
confirm: (value: string) => Promise<boolean | void> | boolean | void;
}) => {
setPromptTitle(opts.title);
setPromptOkText(opts.okText || t('confirm'));
setPromptType(opts.type || 'textarea');
setPromptInitial(opts.value || '');
setPromptJson(opts.json || false);
setPromptHandler(() => opts.confirm);
setPromptOpen(true);
},
[t],
);
const onPromptConfirm = useCallback(async (value: string) => {
if (!promptHandler) {
setPromptOpen(false);
return;
}
setPromptLoading(true);
try {
const ok = await promptHandler(value);
if (ok !== false) setPromptOpen(false);
} finally {
setPromptLoading(false);
}
}, [promptHandler]);
const onPromptConfirm = useCallback(
async (value: string) => {
if (!promptHandler) {
setPromptOpen(false);
return;
}
setPromptLoading(true);
try {
const ok = await promptHandler(value);
if (ok !== false) setPromptOpen(false);
} finally {
setPromptLoading(false);
}
},
[promptHandler],
);
const projectChildThroughMaster = useCallback((child: DBInbound, master: DBInbound): DBInbound => {
const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
projected.listen = master.listen;
projected.port = master.port;
const masterStream = coerceInboundJsonField(master.streamSettings) as Record<string, unknown>;
const childStream = { ...(coerceInboundJsonField(child.streamSettings) as Record<string, unknown>) };
childStream.security = masterStream.security;
childStream.tlsSettings = masterStream.tlsSettings;
childStream.realitySettings = masterStream.realitySettings;
childStream.externalProxy = masterStream.externalProxy;
projected.streamSettings = JSON.stringify(childStream);
const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
return new Ctor(projected);
}, []);
const projectChildThroughMaster = useCallback(
(child: DBInbound, master: DBInbound): DBInbound => {
const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
projected.listen = master.listen;
projected.port = master.port;
const masterStream = coerceInboundJsonField(master.streamSettings) as Record<string, unknown>;
const childStream = {
...(coerceInboundJsonField(child.streamSettings) as Record<string, unknown>),
};
childStream.security = masterStream.security;
childStream.tlsSettings = masterStream.tlsSettings;
childStream.realitySettings = masterStream.realitySettings;
childStream.externalProxy = masterStream.externalProxy;
projected.streamSettings = JSON.stringify(childStream);
const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
return new Ctor(projected);
},
[],
);
const checkFallback = useCallback((dbInbound: DBInbound): DBInbound => {
const parent = dbInbound?.fallbackParent;
if (parent?.masterId) {
const master = dbInbounds.find((ib) => ib.id === parent.masterId);
if (master) return projectChildThroughMaster(dbInbound, master);
}
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds) {
if (candidate.id === dbInbound.id) continue;
if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
if (candStream.network !== 'tcp') continue;
const candSettings = coerceInboundJsonField(candidate.settings) as { fallbacks?: { dest?: string }[] };
const fallbacks = candSettings.fallbacks || [];
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
return projectChildThroughMaster(dbInbound, candidate);
}
return dbInbound;
}, [dbInbounds, projectChildThroughMaster]);
const checkFallback = useCallback(
(dbInbound: DBInbound): DBInbound => {
const parent = dbInbound?.fallbackParent;
if (parent?.masterId) {
const master = dbInbounds.find((ib) => ib.id === parent.masterId);
if (master) return projectChildThroughMaster(dbInbound, master);
}
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds) {
if (candidate.id === dbInbound.id) continue;
if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
if (candStream.network !== 'tcp') continue;
const candSettings = coerceInboundJsonField(candidate.settings) as {
fallbacks?: { dest?: string }[];
};
const fallbacks = candSettings.fallbacks || [];
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
return projectChildThroughMaster(dbInbound, candidate);
}
return dbInbound;
},
[dbInbounds, projectChildThroughMaster],
);
const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
if (!client) return 0;
const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: ClientMatchTarget[] };
const settings = coerceInboundJsonField(dbInbound.settings) as {
clients?: ClientMatchTarget[];
};
const clients = settings.clients || [];
const idx = clients.findIndex((c) => {
if (!c) return false;
@@ -279,53 +316,76 @@ export default function InboundsPage() {
return idx >= 0 ? idx : 0;
}, []);
const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
const projected = checkFallback(dbInbound);
const genInput = {
inbound: inboundFromDb(projected),
remark: projected.remark,
hostOverride: hostOverrideFor(dbInbound),
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
};
const content = genInboundLinks(genInput);
const tabs: TextModalTab[] | undefined = projected.isWireguard
? [
{ key: 'config', label: t('pages.clients.config'), content },
{ key: 'links', label: t('pages.clients.tabLinks'), content: genWireguardLinks(genInput) },
]
: undefined;
openText({
title: t('pages.inbounds.exportLinksTitle'),
content,
fileName: projected.remark || 'inbound',
tabs,
});
}, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
const exportInboundLinks = useCallback(
(dbInbound: DBInbound) => {
const projected = checkFallback(dbInbound);
const genInput = {
inbound: inboundFromDb(projected),
remark: projected.remark,
hostOverride: hostOverrideFor(dbInbound),
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
};
const content = genInboundLinks(genInput);
const tabs: TextModalTab[] | undefined = projected.isWireguard
? [
{ key: 'config', label: t('pages.clients.config'), content },
{
key: 'links',
label: t('pages.clients.tabLinks'),
content: genWireguardLinks(genInput),
},
]
: undefined;
openText({
title: t('pages.inbounds.exportLinksTitle'),
content,
fileName: projected.remark || 'inbound',
tabs,
});
},
[checkFallback, hostOverrideFor, subSettings.publicHost, openText, t],
);
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2), json: true });
}, [openText, t]);
const exportInboundClipboard = useCallback(
(dbInbound: DBInbound) => {
openText({
title: t('pages.inbounds.inboundJsonTitle'),
content: JSON.stringify(dbInbound, null, 2),
json: true,
});
},
[openText, t],
);
const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: { subId?: string }[] };
const clients = settings.clients || [];
const subLinks: string[] = [];
for (const c of clients) {
if (c.subId && subSettings.subURI) {
subLinks.push(subSettings.subURI + c.subId);
const exportInboundSubs = useCallback(
(dbInbound: DBInbound) => {
const settings = coerceInboundJsonField(dbInbound.settings) as {
clients?: { subId?: string }[];
};
const clients = settings.clients || [];
const subLinks: string[] = [];
for (const c of clients) {
if (c.subId && subSettings.subURI) {
subLinks.push(subSettings.subURI + c.subId);
}
}
}
openText({
title: t('pages.inbounds.exportSubsTitle'),
content: [...new Set(subLinks)].join('\n'),
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
});
}, [subSettings, openText, t]);
openText({
title: t('pages.inbounds.exportSubsTitle'),
content: [...new Set(subLinks)].join('\n'),
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
});
},
[subSettings, openText, t],
);
const exportAllLinks = useCallback(async () => {
const msg = await HttpUtil.get('/panel/api/inbounds/allLinks');
const links = msg?.success && Array.isArray(msg.obj) ? (msg.obj as string[]) : [];
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: links.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
openText({
title: t('pages.inbounds.exportAllLinksTitle'),
content: links.join('\r\n'),
fileName: t('pages.inbounds.exportAllLinksFileName'),
});
}, [openText, t]);
const exportAllSubs = useCallback(async () => {
@@ -342,7 +402,11 @@ export default function InboundsPage() {
}
}
}
openText({ title: t('pages.inbounds.exportAllSubsTitle'), content: [...new Set(out)].join('\r\n'), fileName: t('pages.inbounds.exportAllSubsFileName') });
openText({
title: t('pages.inbounds.exportAllSubsTitle'),
content: [...new Set(out)].join('\r\n'),
fileName: t('pages.inbounds.exportAllSubsFileName'),
});
}, [dbInbounds, hydrateInbound, subSettings, openText, t]);
const importInbound = useCallback(() => {
@@ -375,186 +439,250 @@ export default function InboundsPage() {
setFormOpen(true);
}, []);
const confirmDelete = useCallback((dbInbound: DBInbound) => {
modal.confirm({
title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
if (msg?.success) await refresh();
},
});
}, [modal, refresh, t]);
const confirmDelete = useCallback(
(dbInbound: DBInbound) => {
modal.confirm({
title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
if (msg?.success) await refresh();
},
});
},
[modal, refresh, t],
);
const confirmBulkDelete = useCallback((ids: number[]) => new Promise<boolean>((resolve) => {
if (ids.length === 0) {
resolve(false);
return;
}
modal.confirm({
title: t('pages.inbounds.bulkDeleteConfirmTitle', { count: ids.length }),
content: t('pages.inbounds.bulkDeleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post('/panel/api/inbounds/bulkDel', { ids }, { headers: { 'Content-Type': 'application/json' } });
const obj = (msg?.obj ?? {}) as { deleted?: number; skipped?: { id: number; reason: string }[] };
const ok = obj.deleted ?? 0;
const skipped = obj.skipped ?? [];
if (msg?.success && skipped.length === 0) {
messageApi.success(t('pages.inbounds.toasts.bulkDeleted', { count: ok }));
} else {
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
const base = t('pages.inbounds.toasts.bulkDeletedMixed', { ok, failed: skipped.length });
messageApi.warning(firstError ? `${base}${firstError}` : base);
const confirmBulkDelete = useCallback(
(ids: number[]) =>
new Promise<boolean>((resolve) => {
if (ids.length === 0) {
resolve(false);
return;
}
await refresh();
resolve(true);
},
onCancel: () => resolve(false),
});
}), [modal, refresh, t, messageApi]);
const confirmResetTraffic = useCallback((dbInbound: DBInbound) => {
modal.confirm({
title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.resetConfirmContent'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
if (msg?.success) await refresh();
},
});
}, [modal, refresh, t]);
const confirmDelAllClients = useCallback((dbInbound: DBInbound) => {
const count = clientCount[dbInbound.id]?.clients || 0;
modal.confirm({
title: t('pages.inbounds.delAllClientsConfirmTitle', { remark: dbInbound.remark, count }),
content: t('pages.inbounds.delAllClientsConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delAllClients`);
if (msg?.success) await refresh();
},
});
}, [modal, refresh, t, clientCount]);
const confirmClone = useCallback((dbInbound: DBInbound) => {
// Node-eligible protocol with at least one deployable node → open the
// target picker; anything else keeps the original one-click local clone.
if (NODE_ELIGIBLE_PROTOCOLS[dbInbound.protocol] && (nodesList || []).some((n) => n.enable && n.status === 'online')) {
setCloneSource(dbInbound);
setCloneOpen(true);
return;
}
modal.confirm({
title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.cloneConfirmContent'),
okText: t('pages.inbounds.clone'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(
'/panel/api/inbounds/add',
buildClonePayload(dbInbound, RandomUtil.randomInteger(10000, 60000), null),
);
if (msg?.success) await refresh();
},
});
}, [modal, nodesList, refresh, t]);
const onGeneralAction = useCallback((key: GeneralAction) => {
switch (key) {
case 'import': importInbound(); break;
case 'export': exportAllLinks(); break;
case 'subs': exportAllSubs(); break;
case 'resetInbounds':
modal.confirm({
title: t('pages.inbounds.resetAllTrafficTitle'),
okText: t('reset'),
title: t('pages.inbounds.bulkDeleteConfirmTitle', { count: ids.length }),
content: t('pages.inbounds.bulkDeleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
if (msg?.success) await refresh();
const msg = await HttpUtil.post(
'/panel/api/inbounds/bulkDel',
{ ids },
{ headers: { 'Content-Type': 'application/json' } },
);
const obj = (msg?.obj ?? {}) as {
deleted?: number;
skipped?: { id: number; reason: string }[];
};
const ok = obj.deleted ?? 0;
const skipped = obj.skipped ?? [];
if (msg?.success && skipped.length === 0) {
messageApi.success(t('pages.inbounds.toasts.bulkDeleted', { count: ok }));
} else {
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
const base = t('pages.inbounds.toasts.bulkDeletedMixed', {
ok,
failed: skipped.length,
});
messageApi.warning(firstError ? `${base}${firstError}` : base);
}
await refresh();
resolve(true);
},
onCancel: () => resolve(false),
});
break;
default:
messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
}
}, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi, t]);
}),
[modal, refresh, t, messageApi],
);
const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
// Actions that touch per-client secrets (uuid, password, flow, ...) need
// the full payload that the slim list view does not ship. Hydrate first
// and then operate on the rehydrated record.
const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'addToGroup'];
let target = dbInbound;
if (hydratingKeys.includes(key)) {
const hydrated = await hydrateInbound(dbInbound.id);
if (hydrated) target = hydrated;
}
switch (key) {
case 'edit':
openEdit(target);
break;
case 'showInfo':
setInfoDbInbound(checkFallback(target));
setInfoClientIndex(findClientIndex(target, null));
setInfoOpen(true);
break;
case 'qrcode':
setQrDbInbound(checkFallback(target));
setQrOpen(true);
break;
case 'export':
exportInboundLinks(target);
break;
case 'subs':
exportInboundSubs(target);
break;
case 'clipboard':
exportInboundClipboard(target);
break;
case 'delete':
confirmDelete(target);
break;
case 'resetTraffic':
confirmResetTraffic(target);
break;
case 'delAllClients':
confirmDelAllClients(target);
break;
case 'attachClients':
setAttachSource(target);
setAttachOpen(true);
break;
case 'attachExisting':
setAttachExistingTarget(target);
setAttachExistingOpen(true);
break;
case 'detachClients':
setDetachSource(target);
setDetachOpen(true);
break;
case 'addToGroup':
setGroupSource(target);
setGroupOpen(true);
break;
case 'clone':
confirmClone(target);
break;
default:
messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
}
}, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmDelAllClients, confirmClone, messageApi]);
const confirmResetTraffic = useCallback(
(dbInbound: DBInbound) => {
modal.confirm({
title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.resetConfirmContent'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
if (msg?.success) await refresh();
},
});
},
[modal, refresh, t],
);
const confirmDelAllClients = useCallback(
(dbInbound: DBInbound) => {
const count = clientCount[dbInbound.id]?.clients || 0;
modal.confirm({
title: t('pages.inbounds.delAllClientsConfirmTitle', { remark: dbInbound.remark, count }),
content: t('pages.inbounds.delAllClientsConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delAllClients`);
if (msg?.success) await refresh();
},
});
},
[modal, refresh, t, clientCount],
);
const confirmClone = useCallback(
(dbInbound: DBInbound) => {
// Node-eligible protocol with at least one deployable node → open the
// target picker; anything else keeps the original one-click local clone.
if (
NODE_ELIGIBLE_PROTOCOLS[dbInbound.protocol] &&
(nodesList || []).some((n) => n.enable && n.status === 'online')
) {
setCloneSource(dbInbound);
setCloneOpen(true);
return;
}
modal.confirm({
title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.cloneConfirmContent'),
okText: t('pages.inbounds.clone'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(
'/panel/api/inbounds/add',
buildClonePayload(dbInbound, RandomUtil.randomInteger(10000, 60000), null),
);
if (msg?.success) await refresh();
},
});
},
[modal, nodesList, refresh, t],
);
const onGeneralAction = useCallback(
(key: GeneralAction) => {
switch (key) {
case 'import':
importInbound();
break;
case 'export':
exportAllLinks();
break;
case 'subs':
exportAllSubs();
break;
case 'resetInbounds':
modal.confirm({
title: t('pages.inbounds.resetAllTrafficTitle'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
if (msg?.success) await refresh();
},
});
break;
default:
messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
}
},
[modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi, t],
);
const onRowAction = useCallback(
async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
// Actions that touch per-client secrets (uuid, password, flow, ...) need
// the full payload that the slim list view does not ship. Hydrate first
// and then operate on the rehydrated record.
const hydratingKeys: RowAction[] = [
'edit',
'showInfo',
'qrcode',
'export',
'subs',
'clipboard',
'clone',
'attachClients',
'addToGroup',
];
let target = dbInbound;
if (hydratingKeys.includes(key)) {
const hydrated = await hydrateInbound(dbInbound.id);
if (hydrated) target = hydrated;
}
switch (key) {
case 'edit':
openEdit(target);
break;
case 'showInfo':
setInfoDbInbound(checkFallback(target));
setInfoClientIndex(findClientIndex(target, null));
setInfoOpen(true);
break;
case 'qrcode':
setQrDbInbound(checkFallback(target));
setQrOpen(true);
break;
case 'export':
exportInboundLinks(target);
break;
case 'subs':
exportInboundSubs(target);
break;
case 'clipboard':
exportInboundClipboard(target);
break;
case 'delete':
confirmDelete(target);
break;
case 'resetTraffic':
confirmResetTraffic(target);
break;
case 'delAllClients':
confirmDelAllClients(target);
break;
case 'attachClients':
setAttachSource(target);
setAttachOpen(true);
break;
case 'attachExisting':
setAttachExistingTarget(target);
setAttachExistingOpen(true);
break;
case 'detachClients':
setDetachSource(target);
setDetachOpen(true);
break;
case 'addToGroup':
setGroupSource(target);
setGroupOpen(true);
break;
case 'clone':
confirmClone(target);
break;
default:
messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
}
},
[
hydrateInbound,
openEdit,
checkFallback,
findClientIndex,
exportInboundLinks,
exportInboundSubs,
exportInboundClipboard,
confirmDelete,
confirmResetTraffic,
confirmDelAllClients,
confirmClone,
messageApi,
],
);
return (
<ConfigProvider theme={antdThemeConfig}>
@@ -573,7 +701,11 @@ export default function InboundsPage() {
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
extra={
<Button type="primary" onClick={refresh}>
{t('refresh')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, 12]}>
@@ -627,7 +759,9 @@ export default function InboundsPage() {
hasActiveNode={showNodeInfo}
onAddInbound={onAddInbound}
onGeneralAction={onGeneralAction}
onRowAction={({ key, dbInbound }) => onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })}
onRowAction={({ key, dbInbound }) =>
onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })
}
onBulkDelete={confirmBulkDelete}
/>
</Col>
@@ -37,7 +37,9 @@ export default function AddClientsToGroupModal({
const list = Array.isArray(msg?.obj) ? (msg.obj as Array<{ name?: string }>) : [];
setGroups(list.map((g) => g?.name || '').filter(Boolean));
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [open]);
return (
@@ -45,7 +47,9 @@ export default function AddClientsToGroupModal({
open={open}
count={emails.length}
groups={groups}
onOpenChange={(o) => { if (!o) onClose(); }}
onOpenChange={(o) => {
if (!o) onClose();
}}
onSubmit={async (group) => {
const msg = await HttpUtil.post(
'/panel/api/clients/groups/bulkAdd',
@@ -129,7 +129,9 @@ export default function AttachClientsModal({
const skipped = result.skipped?.length ?? 0;
const errors = result.errors?.length ?? 0;
if (errors > 0) {
messageApi.warning(t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }));
messageApi.warning(
t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }),
);
} else {
messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped }));
}
@@ -151,7 +153,9 @@ export default function AttachClientsModal({
}}
okText={t('pages.inbounds.attachClients')}
cancelText={t('cancel')}
title={t('pages.inbounds.attachClientsTitle', { remark: formatInboundLabel(source?.tag, source?.remark) })}
title={t('pages.inbounds.attachClientsTitle', {
remark: formatInboundLabel(source?.tag, source?.remark),
})}
width={680}
>
{messageContextHolder}
@@ -113,14 +113,19 @@ export default function AttachExistingClientsModal({
width: 150,
ellipsis: true,
render: (group: string) =>
group ? <Tag color="geekblue">{group}</Tag> : <span style={{ color: 'rgba(0,0,0,0.45)' }}></span>,
group ? (
<Tag color="geekblue">{group}</Tag>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)' }}></span>
),
},
{
title: t('enable'),
key: 'status',
width: 140,
render: (_v, row) => {
if (row.alreadyAttached) return <Tag color="default">{t('pages.inbounds.attachExistingStatusAttached')}</Tag>;
if (row.alreadyAttached)
return <Tag color="default">{t('pages.inbounds.attachExistingStatusAttached')}</Tag>;
return row.enable ? (
<Tag color="success">{t('enable')}</Tag>
) : (
@@ -150,7 +155,9 @@ export default function AttachExistingClientsModal({
const skipped = result.skipped?.length ?? 0;
const errors = result.errors?.length ?? 0;
if (errors > 0) {
messageApi.warning(t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }));
messageApi.warning(
t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }),
);
} else {
messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped }));
}
@@ -171,7 +178,9 @@ export default function AttachExistingClientsModal({
okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
okText={t('pages.inbounds.attachClients')}
cancelText={t('cancel')}
title={t('pages.inbounds.attachExistingTitle', { remark: formatInboundLabel(target?.tag, target?.remark) })}
title={t('pages.inbounds.attachExistingTitle', {
remark: formatInboundLabel(target?.tag, target?.remark),
})}
width={680}
>
{messageContextHolder}
@@ -116,7 +116,9 @@ export default function DetachClientsModal({
const skipped = result.skipped?.length ?? 0;
const errors = result.errors?.length ?? 0;
if (errors > 0) {
messageApi.warning(t('pages.inbounds.detachClientsResultMixed', { detached, skipped, errors }));
messageApi.warning(
t('pages.inbounds.detachClientsResultMixed', { detached, skipped, errors }),
);
} else {
messageApi.success(t('pages.inbounds.detachClientsResult', { detached, skipped }));
}
@@ -1,6 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Button, Card, Col, Empty, Input, InputNumber, Row, Select, Space } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import type { FallbackRow } from '@/schemas/forms/inbound-form';
@@ -33,7 +38,9 @@ export default function FallbacksCard({
<Button
size="small"
onClick={addAllFallbacks}
disabled={fallbackChildOptions.length === 0 || fallbacks.length >= fallbackChildOptions.length}
disabled={
fallbackChildOptions.length === 0 || fallbacks.length >= fallbackChildOptions.length
}
title={t('pages.inbounds.form.addAllFallbackTooltip')}
>
{t('pages.inbounds.form.addAll')}
@@ -92,7 +99,12 @@ export default function FallbacksCard({
title={t('pages.inbounds.form.moveDown')}
icon={<ArrowDownOutlined />}
/>
<Button aria-label={t('delete')} danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
<Button
aria-label={t('delete')}
danger
onClick={() => removeFallback(idx)}
icon={<DeleteOutlined />}
/>
</Space.Compact>
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}>
@@ -19,10 +19,7 @@ import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
import type { RealityScanResult } from '@/generated/types';
import {
rawInboundToFormValues,
formValuesToWirePayload,
} from '@/lib/xray/inbound-form-adapter';
import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
import {
@@ -87,7 +84,6 @@ import SniffingTab from './SniffingTab';
import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
/* Render a field label with a hover tooltip icon instead of an `extra` help line below. */
const labelWithHint = (label: string, hint: string) => (
<span>
@@ -100,7 +96,8 @@ const labelWithHint = (label: string, hint: string) => (
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
const SHARE_ADDR_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
const SHARE_ADDR_HOSTNAME_RE =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
function isValidShareAddrInput(value: string): boolean {
const v = value.trim();
@@ -152,11 +149,8 @@ function tabForValidationPath(path: PropertyKey[]): string {
if (path[0] === 'settings') return 'protocol';
if (path[0] === 'sniffing') return 'sniffing';
if (path[0] === 'streamSettings') {
if (
path[1] === 'security'
|| path[1] === 'realitySettings'
|| path[1] === 'tlsSettings'
) return 'security';
if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings')
return 'security';
return 'stream';
}
return 'basic';
@@ -201,13 +195,20 @@ function buildAddModeValues(): InboundFormValues {
*/
function newStreamSlice(n: string): Record<string, unknown> {
switch (n) {
case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
case 'kcp': return KcpStreamSettingsSchema.parse({});
case 'ws': return WsStreamSettingsSchema.parse({});
case 'grpc': return GrpcStreamSettingsSchema.parse({});
case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({});
case 'xhttp': return XHttpStreamSettingsSchema.parse({});
default: return {};
case 'tcp':
return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
case 'kcp':
return KcpStreamSettingsSchema.parse({});
case 'ws':
return WsStreamSettingsSchema.parse({});
case 'grpc':
return GrpcStreamSettingsSchema.parse({});
case 'httpupgrade':
return HttpUpgradeStreamSettingsSchema.parse({});
case 'xhttp':
return XHttpStreamSettingsSchema.parse({});
default:
return {};
}
}
@@ -272,9 +273,9 @@ export default function InboundFormModal({
* picker and the per-network sub-forms are hidden.
*/
const hasSelectableTransport =
protocol !== Protocols.HYSTERIA
&& protocol !== Protocols.WIREGUARD
&& protocol !== Protocols.TUNNEL;
protocol !== Protocols.HYSTERIA &&
protocol !== Protocols.WIREGUARD &&
protocol !== Protocols.TUNNEL;
const wPort = useWatch({ control, name: 'port' });
const wListen = (useWatch({ control, name: 'listen' }) ?? '') as string;
@@ -297,9 +298,9 @@ export default function InboundFormModal({
settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
});
const isFallbackHost =
(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
&& network === 'tcp'
&& (security === 'tls' || security === 'reality');
(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
network === 'tcp' &&
(security === 'tls' || security === 'reality');
const {
genRealityKeypair,
@@ -318,8 +319,15 @@ export default function InboundFormModal({
setCertFromPanel,
clearCertFiles,
onSecurityChange,
} = useSecurityActions({ methods, setSaving, messageApi, modal, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
} = useSecurityActions({
methods,
setSaving,
messageApi,
modal,
nodeId: typeof wNodeId === 'number' ? wNodeId : null,
setScanResult,
setScanning,
});
const toggleSockopt = (on: boolean) => {
if (on) {
@@ -329,9 +337,10 @@ export default function InboundFormModal({
}
};
const wgSecretKey = useWatch({ control, name: 'settings.secretKey' });
const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0
? Wireguard.generateKeypair(wgSecretKey).publicKey
: '';
const wgPubKey =
typeof wgSecretKey === 'string' && wgSecretKey.length > 0
? Wireguard.generateKeypair(wgSecretKey).publicKey
: '';
const regenInboundWg = () => {
const kp = Wireguard.generateKeypair();
@@ -344,8 +353,10 @@ export default function InboundFormModal({
) => {
if (block?.id === authId) return true;
const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
if (authId === 'mlkem768') return label.includes('mlkem768') && !label.includes('xorpub') && !label.includes('random');
if (authId === 'x25519') return label.includes('x25519') && !label.includes('xorpub') && !label.includes('random');
if (authId === 'mlkem768')
return label.includes('mlkem768') && !label.includes('xorpub') && !label.includes('random');
if (authId === 'x25519')
return label.includes('x25519') && !label.includes('xorpub') && !label.includes('random');
if (authId === 'mlkem768_xorpub') return label.includes('mlkem768') && label.includes('xorpub');
if (authId === 'mlkem768_random') return label.includes('mlkem768') && label.includes('random');
if (authId === 'x25519_xorpub') return label.includes('x25519') && label.includes('xorpub');
@@ -388,9 +399,8 @@ export default function InboundFormModal({
useEffect(() => {
if (!open) return;
const initial = mode === 'edit' && dbInbound
? rawInboundToFormValues(dbInbound)
: buildAddModeValues();
const initial =
mode === 'edit' && dbInbound ? rawInboundToFormValues(dbInbound) : buildAddModeValues();
methods.reset(initial);
setScanResult(null);
setActiveTab('basic');
@@ -404,9 +414,9 @@ export default function InboundFormModal({
});
lastWrittenTagRef.current = initialTag;
if (
mode === 'edit'
&& dbInbound
&& (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN)
mode === 'edit' &&
dbInbound &&
(dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN)
) {
loadFallbacks(dbInbound.id);
} else {
@@ -479,10 +489,12 @@ export default function InboundFormModal({
tlsSettings: createHysteriaTlsSettingsWithDefaultCert(),
finalmask: {
tcp: [],
udp: [{
type: 'salamander',
settings: { password: RandomUtil.randomLowerAndNum(16) },
}],
udp: [
{
type: 'salamander',
settings: { password: RandomUtil.randomLowerAndNum(16) },
},
],
},
});
} else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) {
@@ -520,16 +532,15 @@ export default function InboundFormModal({
setSaving(true);
try {
const payload = formValuesToWirePayload(parsed.data);
const url = mode === 'edit' && dbInbound
? `/panel/api/inbounds/update/${dbInbound.id}`
: '/panel/api/inbounds/add';
const url =
mode === 'edit' && dbInbound
? `/panel/api/inbounds/update/${dbInbound.id}`
: '/panel/api/inbounds/add';
const msg = await HttpUtil.post(url, payload);
if (msg?.success) {
if (isFallbackHost) {
const obj = msg.obj as { id?: number; Id?: number } | null;
const masterId = mode === 'edit'
? dbInbound!.id
: (obj?.id ?? obj?.Id ?? 0);
const masterId = mode === 'edit' ? dbInbound!.id : (obj?.id ?? obj?.Id ?? 0);
if (masterId) await saveFallbacks(masterId);
}
onSaved();
@@ -551,13 +562,10 @@ export default function InboundFormModal({
messageApi.error(formatInboundIssue(issue, methods.getValues(), t));
});
const title = mode === 'edit'
? t('pages.inbounds.modifyInbound')
: t('pages.inbounds.addInbound');
const title =
mode === 'edit' ? t('pages.inbounds.modifyInbound') : t('pages.inbounds.addInbound');
const okText = mode === 'edit'
? t('pages.clients.submitEdit')
: t('create');
const okText = mode === 'edit' ? t('pages.clients.submitEdit') : t('create');
const basicTab = (
<>
@@ -600,22 +608,28 @@ export default function InboundFormModal({
<FormField
name="shareAddrStrategy"
label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))}
label={labelWithHint(
t('pages.inbounds.form.shareAddrStrategy'),
t('pages.inbounds.form.shareAddrStrategyHelp'),
)}
>
<Select
options={SHARE_ADDR_STRATEGIES
.filter((strategy) => strategy !== 'node' || nodeShareOptionAvailable)
.map((strategy) => ({
value: strategy,
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
}))}
options={SHARE_ADDR_STRATEGIES.filter(
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
).map((strategy) => ({
value: strategy,
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
}))}
/>
</FormField>
{shareAddrStrategy === 'custom' && (
<FormField
name="shareAddr"
label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))}
label={labelWithHint(
t('pages.inbounds.form.shareAddr'),
t('pages.inbounds.form.shareAddrHelp'),
)}
rules={{
validate: (value) =>
isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'),
@@ -627,7 +641,10 @@ export default function InboundFormModal({
<FormField
name="subSortIndex"
label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))}
label={labelWithHint(
t('pages.inbounds.form.subSortIndex'),
t('pages.inbounds.form.subSortIndexHelp'),
)}
>
<InputNumber min={1} />
</FormField>
@@ -636,7 +653,10 @@ export default function InboundFormModal({
<FormField
name="disableFlow"
valueProp="checked"
label={labelWithHint(t('pages.inbounds.form.disableFlow'), t('pages.inbounds.form.disableFlowHelp'))}
label={labelWithHint(
t('pages.inbounds.form.disableFlow'),
t('pages.inbounds.form.disableFlowHelp'),
)}
>
<Switch />
</FormField>
@@ -716,7 +736,9 @@ export default function InboundFormModal({
const protocolTab = (
<>
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />}
{protocol === Protocols.WIREGUARD && (
<WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />
)}
{protocol === Protocols.TUN && <TunFields />}
@@ -729,11 +751,22 @@ export default function InboundFormModal({
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields isSSWith2022={isSSWith2022} />}
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} vlessAuthKind={vlessAuthKind} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
{protocol === Protocols.VLESS && (
<VlessFields
saving={saving}
selectedVlessAuth={selectedVlessAuth}
vlessAuthKind={vlessAuthKind}
network={network}
security={security}
getNewVlessEnc={getNewVlessEnc}
clearVlessEnc={clearVlessEnc}
/>
)}
{isFallbackHost && fallbacksCard}
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
&& network === 'tcp' && !isFallbackHost && (
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
network === 'tcp' &&
!isFallbackHost && (
<Alert
className="mt-12"
type="info"
@@ -750,7 +783,14 @@ export default function InboundFormModal({
* FinalMask mkcp-legacy UDP mask when moving to mKCP (removed otherwise).
*/
const onNetworkChange = (next: string) => {
const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
const ALL = [
'tcpSettings',
'kcpSettings',
'wsSettings',
'grpcSettings',
'httpupgradeSettings',
'xhttpSettings',
];
const current = (getV('streamSettings') as Record<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...current, network: next };
for (const k of ALL) {
@@ -773,7 +813,9 @@ export default function InboundFormModal({
} else {
const fm = cleaned.finalmask as Record<string, unknown> | undefined;
if (fm && Array.isArray(fm.udp)) {
const udp = (fm.udp as unknown[]).filter((m) => (m as { type?: string })?.type !== 'mkcp-legacy');
const udp = (fm.udp as unknown[]).filter(
(m) => (m as { type?: string })?.type !== 'mkcp-legacy',
);
cleaned.finalmask = { ...fm, udp };
}
}
@@ -914,10 +956,11 @@ export default function InboundFormModal({
label: t('pages.inbounds.advanced.all'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.allHelp')}
</div>
<AdvancedAllEditor streamEnabled={streamEnabled} sniffingEnabled={sniffingSupported} />
<div className="advanced-editor-meta">{t('pages.inbounds.advanced.allHelp')}</div>
<AdvancedAllEditor
streamEnabled={streamEnabled}
sniffingEnabled={sniffingSupported}
/>
</>
),
},
@@ -940,44 +983,48 @@ export default function InboundFormModal({
),
},
...(streamEnabled
? [{
key: 'stream',
label: t('pages.inbounds.advanced.stream'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.streamHelp')}{' '}
<code>{'{ streamSettings: { ... } }'}</code>.
</div>
<AdvancedSliceEditor
path="streamSettings"
wrapKey="streamSettings"
minHeight="320px"
maxHeight="540px"
/>
</>
),
}]
? [
{
key: 'stream',
label: t('pages.inbounds.advanced.stream'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.streamHelp')}{' '}
<code>{'{ streamSettings: { ... } }'}</code>.
</div>
<AdvancedSliceEditor
path="streamSettings"
wrapKey="streamSettings"
minHeight="320px"
maxHeight="540px"
/>
</>
),
},
]
: []),
...(sniffingSupported
? [{
key: 'sniffing',
label: t('pages.inbounds.advanced.sniffing'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.sniffingHelp')}{' '}
<code>{'{ sniffing: { ... } }'}</code>.
</div>
<AdvancedSliceEditor
path="sniffing"
wrapKey="sniffing"
minHeight="240px"
maxHeight="420px"
/>
</>
),
}]
? [
{
key: 'sniffing',
label: t('pages.inbounds.advanced.sniffing'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.sniffingHelp')}{' '}
<code>{'{ sniffing: { ... } }'}</code>.
</div>
<AdvancedSliceEditor
path="sniffing"
wrapKey="sniffing"
minHeight="240px"
maxHeight="420px"
/>
</>
),
},
]
: []),
]}
/>
@@ -1010,33 +1057,75 @@ export default function InboundFormModal({
wrapperCol={{ sm: { span: 14 } }}
labelWrap
>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
{ key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
...(([
Protocols.VLESS,
Protocols.SHADOWSOCKS,
Protocols.HTTP,
Protocols.MIXED,
Protocols.TUNNEL,
Protocols.TUN,
Protocols.WIREGUARD,
Protocols.MTPROTO,
] as string[]).includes(protocol) || isFallbackHost
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
: []),
...(streamEnabled
? [
{ key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
...(protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL
? [{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true }]
: []),
]
: []),
...(sniffingSupported
? [{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true }]
: []),
{ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
]} />
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'basic',
label: t('pages.xray.basicTemplate'),
children: basicTab,
forceRender: true,
},
...((
[
Protocols.VLESS,
Protocols.SHADOWSOCKS,
Protocols.HTTP,
Protocols.MIXED,
Protocols.TUNNEL,
Protocols.TUN,
Protocols.WIREGUARD,
Protocols.MTPROTO,
] as string[]
).includes(protocol) || isFallbackHost
? [
{
key: 'protocol',
label: t('pages.inbounds.protocol'),
children: protocolTab,
forceRender: true,
},
]
: []),
...(streamEnabled
? [
{
key: 'stream',
label: t('pages.inbounds.streamTab'),
children: streamTab,
forceRender: true,
},
...(protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL
? [
{
key: 'security',
label: t('pages.inbounds.securityTab'),
children: securityTab,
forceRender: true,
},
]
: []),
]
: []),
...(sniffingSupported
? [
{
key: 'sniffing',
label: t('pages.inbounds.sniffingTab'),
children: sniffingTab,
forceRender: true,
},
]
: []),
{
key: 'advanced',
label: t('pages.xray.advancedTemplate'),
children: advancedTab,
forceRender: true,
},
]}
/>
</Form>
</FormProvider>
</Modal>
@@ -11,11 +11,7 @@ export default function SniffingTab() {
control={control}
name="sniffing"
render={({ field }) => (
<SniffingField
value={field.value}
onChange={field.onChange}
enableLabel={t('enable')}
/>
<SniffingField value={field.value} onChange={field.onChange} enableLabel={t('enable')} />
)}
/>
);
@@ -67,9 +67,10 @@ export function AdvancedSliceEditor({
setText(next);
try {
const parsed = JSON.parse(next);
const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)[wrapKey] ?? {}
: parsed;
const toWrite =
wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? ((parsed as Record<string, unknown>)[wrapKey] ?? {})
: parsed;
setValue(path, toWrite);
lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2);
} catch {
@@ -146,7 +147,17 @@ export function AdvancedAllEditor({
setText(formStr);
lastEmitRef.current = formStr;
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled, sniffingEnabled]);
}, [
wListen,
wPort,
wProtocol,
wTag,
wSettings,
wSniffing,
wStream,
streamEnabled,
sniffingEnabled,
]);
return (
<JsonEditor
@@ -36,7 +36,11 @@ export function formatInboundIssue(issue: IssueLike, values: unknown, t: TFuncti
* Builds the single-line toast for a failed inbound save: the first issue,
* fully described, plus a "(+N more)" tail when several fields failed.
*/
export function formatInboundValidation(issues: IssueLike[], values: unknown, t: TFunction): string {
export function formatInboundValidation(
issues: IssueLike[],
values: unknown,
t: TFunction,
): string {
const first = formatInboundIssue(issues[0], values, t);
if (issues.length <= 1) return first;
return t('pages.inbounds.toasts.moreIssues', { message: first, count: issues.length - 1 });
@@ -16,10 +16,12 @@ export default function AccountsList() {
<Form.Item label={t('pages.inbounds.form.accounts')}>
<Button
size="small"
onClick={() => append({
user: RandomUtil.randomLowerAndNum(8),
pass: RandomUtil.randomLowerAndNum(12),
})}
onClick={() =>
append({
user: RandomUtil.randomLowerAndNum(8),
pass: RandomUtil.randomLowerAndNum(12),
})
}
>
<PlusOutlined /> {t('add')}
</Button>
@@ -13,9 +13,10 @@ export default function HysteriaFields() {
const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as
| { type?: string }
| undefined;
const masqType = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade.type' }) as
| string
| undefined;
const masqType = useWatch({
control,
name: 'streamSettings.hysteriaSettings.masquerade.type',
}) as string | undefined;
return (
<>
<FormField
@@ -39,10 +40,15 @@ export default function HysteriaFields() {
'streamSettings.hysteriaSettings.masquerade',
checked
? {
type: '', dir: '', url: '',
rewriteHost: false, insecure: false,
content: '', headers: {}, statusCode: 0,
}
type: '',
dir: '',
url: '',
rewriteHost: false,
insecure: false,
content: '',
headers: {},
statusCode: 0,
}
: undefined,
)
}
@@ -50,10 +56,7 @@ export default function HysteriaFields() {
</Form.Item>
{masq && (
<>
<FormField
label={t('pages.inbounds.form.type')}
name={[...MASQ_PATH, 'type']}
>
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ_PATH, 'type']}>
<Select
options={[
{ value: '', label: 'default (404 page)' },
@@ -65,10 +68,7 @@ export default function HysteriaFields() {
</FormField>
{masqType === 'proxy' && (
<>
<FormField
label={t('pages.inbounds.form.upstreamUrl')}
name={[...MASQ_PATH, 'url']}
>
<FormField label={t('pages.inbounds.form.upstreamUrl')} name={[...MASQ_PATH, 'url']}>
<Input placeholder="https://www.example.com" />
</FormField>
<FormField
@@ -88,10 +88,7 @@ export default function HysteriaFields() {
</>
)}
{masqType === 'file' && (
<FormField
label={t('pages.inbounds.form.directory')}
name={[...MASQ_PATH, 'dir']}
>
<FormField label={t('pages.inbounds.form.directory')} name={[...MASQ_PATH, 'dir']}>
<Input placeholder="/var/www/html" />
</FormField>
)}
@@ -103,16 +100,10 @@ export default function HysteriaFields() {
>
<InputNumber min={0} max={599} style={{ width: '100%' }} />
</FormField>
<FormField
label={t('pages.inbounds.form.body')}
name={[...MASQ_PATH, 'content']}
>
<FormField label={t('pages.inbounds.form.body')} name={[...MASQ_PATH, 'content']}>
<Input.TextArea autoSize={{ minRows: 3 }} />
</FormField>
<FormField
label={t('pages.inbounds.form.headers')}
name={[...MASQ_PATH, 'headers']}
>
<FormField label={t('pages.inbounds.form.headers')} name={[...MASQ_PATH, 'headers']}>
<HeaderMapEditor mode="v1" />
</FormField>
</>
@@ -17,11 +17,7 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
]}
/>
</FormField>
<FormField
name={['settings', 'udp']}
label="UDP"
valueProp="checked"
>
<FormField name={['settings', 'udp']} label="UDP" valueProp="checked">
<Switch />
</FormField>
{mixedUdpOn && (
@@ -8,7 +8,9 @@ import { useOutboundTags } from '@/api/queries/useOutboundTags';
export default function MtprotoFields() {
const { t } = useTranslation();
const { control } = useFormContext();
const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as boolean | undefined;
const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as
| boolean
| undefined;
const { data: outboundTags } = useOutboundTags();
return (
<>
@@ -26,7 +28,10 @@ export default function MtprotoFields() {
>
<Input placeholder="127.0.0.1" />
</FormField>
<FormField name={['settings', 'domainFronting', 'port']} label={t('pages.inbounds.form.mtgDomainFrontingPort')}>
<FormField
name={['settings', 'domainFronting', 'port']}
label={t('pages.inbounds.form.mtgDomainFrontingPort')}
>
<InputNumber min={0} max={65535} placeholder="443" style={{ width: '100%' }} />
</FormField>
<FormField
@@ -55,7 +60,11 @@ export default function MtprotoFields() {
]}
/>
</FormField>
<FormField name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valueProp="checked">
<FormField
name={['settings', 'debug']}
label={t('pages.inbounds.form.mtgDebug')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
@@ -23,9 +23,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
}}
>
<Select
options={SSMethodSchema.options.map((m) => ({ value: m, label: m }))}
/>
<Select options={SSMethodSchema.options.map((m) => ({ value: m, label: m }))} />
</FormField>
{isSSWith2022 && (
<Form.Item label={t('password')}>
@@ -57,11 +55,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
]}
/>
</FormField>
<FormField
name={['settings', 'ivCheck']}
label="ivCheck"
valueProp="checked"
>
<FormField name={['settings', 'ivCheck']} label="ivCheck" valueProp="checked">
<Switch />
</FormField>
</>
@@ -8,13 +8,19 @@ export default function TunnelFields() {
const { t } = useTranslation();
return (
<>
<FormField name={['settings', 'rewriteAddress']} label={t('pages.inbounds.form.rewriteAddress')}>
<FormField
name={['settings', 'rewriteAddress']}
label={t('pages.inbounds.form.rewriteAddress')}
>
<Input />
</FormField>
<FormField name={['settings', 'rewritePort']} label={t('pages.inbounds.form.rewritePort')}>
<InputNumber min={0} max={65535} />
</FormField>
<FormField name={['settings', 'allowedNetwork']} label={t('pages.inbounds.form.allowedNetwork')}>
<FormField
name={['settings', 'allowedNetwork']}
label={t('pages.inbounds.form.allowedNetwork')}
>
<Select
options={[
{ value: 'tcp,udp', label: 'TCP, UDP' },
@@ -56,7 +56,9 @@ export default function VlessFields({
<Button type="primary" loading={saving} onClick={() => getNewVlessEnc(authKind)}>
{t('pages.inbounds.vlessAuthGenerateButton')}
</Button>
<Button danger onClick={clearVlessEnc}>{t('clear')}</Button>
<Button danger onClick={clearVlessEnc}>
{t('clear')}
</Button>
</Space>
<Typography.Text type="secondary" className="vless-auth-state">
{t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth })}
@@ -37,7 +37,10 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
>
<Switch />
</FormField>
<FormField name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}>
<FormField
name={['settings', 'domainStrategy']}
label={t('pages.xray.wireguard.domainStrategy')}
>
<Select
allowClear
options={[
@@ -49,7 +49,9 @@ export default function RealityTargetScannerModal({
render: (target: string, row) => (
<Tooltip title={row.ip ? `${target}${row.ip}` : target}>
<div style={{ lineHeight: 1.25 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{target}</div>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{target}
</div>
{row.ip ? <div style={{ color: '#999', fontSize: 12 }}>{row.ip}</div> : null}
</div>
</Tooltip>
@@ -1,7 +1,19 @@
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import {
Alert,
Button,
Collapse,
Descriptions,
Divider,
Form,
Input,
InputNumber,
Select,
Space,
Switch,
} from 'antd';
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
@@ -52,9 +64,10 @@ export default function RealityForm({
* worth reading, so subject/issuer stay visible and only the verdict is added.
*/
const certSummary = (r: RealityScanResult) => {
const who = r.certSubject && r.certIssuer
? `${r.certSubject} (${r.certIssuer})`
: r.certSubject || r.certIssuer;
const who =
r.certSubject && r.certIssuer
? `${r.certSubject} (${r.certIssuer})`
: r.certSubject || r.certIssuer;
if (!who) return '—';
return r.certValid ? who : `${who}${t('pages.inbounds.form.scanCertInvalid')}`;
};
@@ -73,16 +86,17 @@ export default function RealityForm({
>
<Switch />
</FormField>
<FormField name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
<FormField
name={['streamSettings', 'realitySettings', 'xver']}
label={t('pages.inbounds.form.xver')}
>
<InputNumber min={0} />
</FormField>
<FormField
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
label="uTLS"
>
<Select
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/>
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
</FormField>
<Form.Item
label={t('pages.inbounds.form.target')}
@@ -101,7 +115,11 @@ export default function RealityForm({
>
<Input style={{ flex: 1 }} placeholder="example.com:443" />
</FormField>
<Button icon={<RadarChartOutlined />} loading={scanning} onClick={() => scanRealityTarget()}>
<Button
icon={<RadarChartOutlined />}
loading={scanning}
onClick={() => scanRealityTarget()}
>
{t('pages.inbounds.form.scan')}
</Button>
<Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
@@ -119,7 +137,7 @@ export default function RealityForm({
? t('pages.inbounds.form.scanFeasible')
: scanResult.reason || t('pages.inbounds.form.scanNotFeasible')
}
description={(
description={
<>
{scanResult.privateTarget && (
<div style={{ marginBottom: 8 }}>{t('pages.inbounds.form.scanPrivateNote')}</div>
@@ -137,14 +155,16 @@ export default function RealityForm({
{certSummary(scanResult)}
</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanCertExpiry')}>
{scanResult.notAfter ? dayjs(scanResult.notAfter).format('YYYY-MM-DD HH:mm') : '—'}
{scanResult.notAfter
? dayjs(scanResult.notAfter).format('YYYY-MM-DD HH:mm')
: '—'}
</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
{scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
}
/>
</Form.Item>
)}
@@ -188,13 +208,14 @@ export default function RealityForm({
</FormField>
<Form.Item label={t('pages.inbounds.form.shortIds')}>
<Space.Compact block style={{ display: 'flex' }}>
<FormField
name={['streamSettings', 'realitySettings', 'shortIds']}
noStyle
>
<FormField name={['streamSettings', 'realitySettings', 'shortIds']} noStyle>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</FormField>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} />
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={randomizeShortIds}
/>
</Space.Compact>
</Form.Item>
<Form.Item
@@ -202,13 +223,14 @@ export default function RealityForm({
tooltip={t('pages.inbounds.form.spiderXHint')}
>
<Space.Compact block style={{ display: 'flex' }}>
<FormField
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
noStyle
>
<FormField name={['streamSettings', 'realitySettings', 'settings', 'spiderX']} noStyle>
<Input style={{ flex: 1 }} />
</FormField>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} />
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={randomizeSpiderX}
/>
</Space.Compact>
</Form.Item>
<FormField
@@ -228,7 +250,9 @@ export default function RealityForm({
<Button type="primary" loading={saving} onClick={genRealityKeypair}>
{t('pages.inbounds.form.getNewCert')}
</Button>
<Button danger onClick={clearRealityKeypair}>{t('clear')}</Button>
<Button danger onClick={clearRealityKeypair}>
{t('clear')}
</Button>
</Space>
</Form.Item>
<FormField
@@ -248,7 +272,9 @@ export default function RealityForm({
<Button type="primary" loading={saving} onClick={genMldsa65}>
{t('pages.inbounds.form.getNewSeed')}
</Button>
<Button danger onClick={clearMldsa65}>{t('clear')}</Button>
<Button danger onClick={clearMldsa65}>
{t('clear')}
</Button>
</Space>
</Form.Item>
<FormField
@@ -1,6 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
import { CloudDownloadOutlined, FileProtectOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons';
import {
CloudDownloadOutlined,
FileProtectOutlined,
MinusOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
@@ -40,11 +45,24 @@ interface CertRowProps {
clearCertFiles: (certName: number) => void;
}
function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFiles }: CertRowProps) {
function CertRow({
index,
total,
saving,
onRemove,
setCertFromPanel,
clearCertFiles,
}: CertRowProps) {
const { t } = useTranslation();
const { control } = useFormContext();
const useFile = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.useFile` });
const usage = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.usage` });
const useFile = useWatch({
control,
name: `streamSettings.tlsSettings.certificates.${index}.useFile`,
});
const usage = useWatch({
control,
name: `streamSettings.tlsSettings.certificates.${index}.usage`,
});
return (
<div>
<FormField
@@ -52,12 +70,8 @@ function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFi
label={`${t('certificate')} ${index + 1}`}
>
<Radio.Group buttonStyle="solid">
<Radio.Button value={true}>
{t('pages.inbounds.certificatePath')}
</Radio.Button>
<Radio.Button value={false}>
{t('pages.inbounds.certificateContent')}
</Radio.Button>
<Radio.Button value={true}>{t('pages.inbounds.certificatePath')}</Radio.Button>
<Radio.Button value={false}>{t('pages.inbounds.certificateContent')}</Radio.Button>
</Radio.Group>
</FormField>
{total > 1 && (
@@ -83,11 +97,7 @@ function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFi
</FormField>
<Form.Item label=" ">
<Space>
<Button
type="primary"
loading={saving}
onClick={() => setCertFromPanel(index)}
>
<Button type="primary" loading={saving} onClick={() => setCertFromPanel(index)}>
{t('pages.inbounds.setDefaultCert')}
</Button>
<Button danger onClick={() => clearCertFiles(index)}>
@@ -156,7 +166,10 @@ function EchSockoptSection() {
const on = !!echSockopt;
return (
<>
<Form.Item label={t('pages.inbounds.form.echSockopt')} tooltip={t('pages.inbounds.form.echSockoptTip')}>
<Form.Item
label={t('pages.inbounds.form.echSockopt')}
tooltip={t('pages.inbounds.form.echSockoptTip')}
>
<Switch
checked={on}
onChange={(v) =>
@@ -223,7 +236,10 @@ export default function TlsForm({
<FormField name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
</FormField>
<FormField name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
<FormField
name={['streamSettings', 'tlsSettings', 'cipherSuites']}
label={t('pages.inbounds.form.cipherSuites')}
>
<Select
options={[
{ value: '', label: t('pages.inbounds.form.autoOption') },
@@ -247,10 +263,7 @@ export default function TlsForm({
</FormField>
</Space.Compact>
</Form.Item>
<FormField
name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']}
label="uTLS"
>
<FormField name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']} label="uTLS">
<Select
options={[
{ value: '', label: 'None' },
@@ -308,17 +321,19 @@ export default function TlsForm({
aria-label={t('add')}
type="primary"
size="small"
onClick={() => append({
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
ocspStapling: 0,
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
})}
onClick={() =>
append({
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
ocspStapling: 0,
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
})
}
>
<PlusOutlined />
</Button>
@@ -342,7 +357,10 @@ export default function TlsForm({
<Input placeholder="/path/to/sslkeylog.txt" />
</FormField>
<EchSockoptSection />
<FormField name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
<FormField
name={['streamSettings', 'tlsSettings', 'echServerKeys']}
label={t('pages.inbounds.form.echKey')}
>
<Input />
</FormField>
<FormField
@@ -356,7 +374,9 @@ export default function TlsForm({
<Button type="primary" loading={saving} onClick={getNewEchCert}>
{t('pages.inbounds.form.getNewEchCert')}
</Button>
<Button danger onClick={clearEchCert}>{t('clear')}</Button>
<Button danger onClick={clearEchCert}>
{t('clear')}
</Button>
</Space>
</Form.Item>
<Form.Item
@@ -15,16 +15,10 @@ export default function HttpUpgradeForm() {
>
<Switch />
</FormField>
<FormField
name={['streamSettings', 'httpupgradeSettings', 'host']}
label={t('host')}
>
<FormField name={['streamSettings', 'httpupgradeSettings', 'host']} label={t('host')}>
<Input />
</FormField>
<FormField
name={['streamSettings', 'httpupgradeSettings', 'path']}
label={t('path')}
>
<FormField name={['streamSettings', 'httpupgradeSettings', 'path']} label={t('path')}>
<Input />
</FormField>
<FormField
@@ -10,13 +10,22 @@ export default function KcpForm() {
<FormField name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU">
<InputNumber min={576} max={1460} />
</FormField>
<FormField name={['streamSettings', 'kcpSettings', 'tti']} label={t('pages.inbounds.form.ttiMs')}>
<FormField
name={['streamSettings', 'kcpSettings', 'tti']}
label={t('pages.inbounds.form.ttiMs')}
>
<InputNumber min={10} max={100} />
</FormField>
<FormField name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} label={t('pages.inbounds.form.uplinkMbps')}>
<FormField
name={['streamSettings', 'kcpSettings', 'uplinkCapacity']}
label={t('pages.inbounds.form.uplinkMbps')}
>
<InputNumber min={0} />
</FormField>
<FormField name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} label={t('pages.inbounds.form.downlinkMbps')}>
<FormField
name={['streamSettings', 'kcpSettings', 'downlinkCapacity']}
label={t('pages.inbounds.form.downlinkMbps')}
>
<InputNumber min={0} />
</FormField>
<FormField
@@ -29,20 +29,20 @@ export default function RawForm() {
'streamSettings.tcpSettings.header',
v
? {
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' },
);
}}
@@ -37,7 +37,9 @@ export default function SockoptForm({
const sockTrusted = useWatch({ control, name: 'streamSettings.sockopt.trustedXForwardedFor' });
const transportAcceptPP = useWatch({
control,
name: transportField ? `streamSettings.${transportField}.acceptProxyProtocol` : 'streamSettings.__noTransportProxyField',
name: transportField
? `streamSettings.${transportField}.acceptProxyProtocol`
: 'streamSettings.__noTransportProxyField',
});
/* Presets write the same sockopt fields the user could set by hand below,
@@ -103,7 +105,10 @@ export default function SockoptForm({
onChange={(v) => applyRealClientIpPreset(v as RealClientIpPreset)}
options={[
{ value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
{ value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
{
value: 'cloudflare',
label: t('pages.inbounds.form.realClientIpPresetCloudflare'),
},
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
]}
/>
@@ -124,7 +129,10 @@ export default function SockoptForm({
title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
/>
)}
<FormField name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
<FormField
name={['streamSettings', 'sockopt', 'mark']}
label={t('pages.inbounds.form.routeMark')}
>
<InputNumber min={0} />
</FormField>
<FormField
@@ -139,7 +147,10 @@ export default function SockoptForm({
>
<InputNumber min={0} />
</FormField>
<FormField name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label={t('pages.inbounds.form.tcpMaxSeg')}>
<FormField
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
label={t('pages.inbounds.form.tcpMaxSeg')}
>
<InputNumber min={0} />
</FormField>
<FormField
@@ -22,12 +22,29 @@ function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>
export default function XhttpForm() {
const { t } = useTranslation();
const { control, getValues, setValue } = useFormContext();
const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as string | undefined;
const xhttpObfsMode = !!useWatch({ control, name: 'streamSettings.xhttpSettings.xPaddingObfsMode' });
const xhttpSessionIDPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDPlacement' }) as string | undefined;
const xhttpSessionIDTable = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDTable' });
const xhttpSeqPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.seqPlacement' }) as string | undefined;
const xhttpUplinkPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.uplinkDataPlacement' }) as string | undefined;
const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as
| string
| undefined;
const xhttpObfsMode = !!useWatch({
control,
name: 'streamSettings.xhttpSettings.xPaddingObfsMode',
});
const xhttpSessionIDPlacement = useWatch({
control,
name: 'streamSettings.xhttpSettings.sessionIDPlacement',
}) as string | undefined;
const xhttpSessionIDTable = useWatch({
control,
name: 'streamSettings.xhttpSettings.sessionIDTable',
});
const xhttpSeqPlacement = useWatch({
control,
name: 'streamSettings.xhttpSettings.seqPlacement',
}) as string | undefined;
const xhttpUplinkPlacement = useWatch({
control,
name: 'streamSettings.xhttpSettings.uplinkDataPlacement',
}) as string | undefined;
const enableXmux = !!useWatch({ control, name: 'streamSettings.xhttpSettings.enableXmux' });
function onXmuxToggle(checked: boolean) {
@@ -60,7 +77,10 @@ export default function XhttpForm() {
<FormField name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}>
<Input />
</FormField>
<FormField name={['streamSettings', 'xhttpSettings', 'mode']} label={t('pages.inbounds.info.mode')}>
<FormField
name={['streamSettings', 'xhttpSettings', 'mode']}
label={t('pages.inbounds.info.mode')}
>
<Select
style={{ width: '50%' }}
options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
@@ -29,44 +29,45 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
return;
}
setFallbacks(
(msg.obj as {
childId: number;
name?: string;
alpn?: string;
path?: string;
dest?: string;
xver?: number;
}[])
.map((r) => ({
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: r.childId && r.childId > 0 ? r.childId : null,
name: r.name || '',
alpn: r.alpn || '',
path: r.path || '',
dest: r.dest || '',
xver: r.xver || 0,
})),
(
msg.obj as {
childId: number;
name?: string;
alpn?: string;
path?: string;
dest?: string;
xver?: number;
}[]
).map((r) => ({
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: r.childId && r.childId > 0 ? r.childId : null,
name: r.name || '',
alpn: r.alpn || '',
path: r.path || '',
dest: r.dest || '',
xver: r.xver || 0,
})),
);
};
const saveFallbacks = async (masterId: number) => {
if (!masterId) return true;
const payload = {
fallbacks: fallbacks.filter((c) => c.childId || (c.dest ?? '').trim()).map((c, i) => ({
childId: c.childId,
name: c.name,
alpn: c.alpn,
path: c.path,
dest: c.dest,
xver: Number(c.xver) || 0,
sortOrder: i,
})),
fallbacks: fallbacks
.filter((c) => c.childId || (c.dest ?? '').trim())
.map((c, i) => ({
childId: c.childId,
name: c.name,
alpn: c.alpn,
path: c.path,
dest: c.dest,
xver: Number(c.xver) || 0,
sortOrder: i,
})),
};
const msg = await HttpUtil.post(
`/panel/api/inbounds/${masterId}/fallbacks`,
payload,
{ headers: { 'Content-Type': 'application/json' } },
);
const msg = await HttpUtil.post(`/panel/api/inbounds/${masterId}/fallbacks`, payload, {
headers: { 'Content-Type': 'application/json' },
});
return !!msg?.success;
};
@@ -104,30 +105,35 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
};
const addFallback = () => {
setFallbacks((prev) => [...prev, {
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: null,
name: '',
alpn: '',
path: '',
dest: '',
xver: 0,
}]);
setFallbacks((prev) => [
...prev,
{
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: null,
name: '',
alpn: '',
path: '',
dest: '',
xver: 0,
},
]);
};
const updateFallback = (rowKey: string, patch: Partial<FallbackRow>) => {
setFallbacks((prev) => prev.map((r) => {
if (r.rowKey !== rowKey) return r;
// When the picker selects a new child inbound and the row hasn't
// been hand-edited yet (sni/alpn/path/dest all blank, xver = 0),
// pull the SNI/ALPN/Path defaults off that child. Operators who
// intentionally typed values keep them — we only fill the empties.
if (typeof patch.childId === 'number' && patch.childId !== r.childId) {
const isPristine = !r.name && !r.alpn && !r.path && !r.dest && r.xver === 0;
if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) };
}
return { ...r, ...patch };
}));
setFallbacks((prev) =>
prev.map((r) => {
if (r.rowKey !== rowKey) return r;
// When the picker selects a new child inbound and the row hasn't
// been hand-edited yet (sni/alpn/path/dest all blank, xver = 0),
// pull the SNI/ALPN/Path defaults off that child. Operators who
// intentionally typed values keep them — we only fill the empties.
if (typeof patch.childId === 'number' && patch.childId !== r.childId) {
const isPristine = !r.name && !r.alpn && !r.path && !r.dest && r.xver === 0;
if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) };
}
return { ...r, ...patch };
}),
);
};
const removeFallback = (idx: number) => {
@@ -31,7 +31,15 @@ interface UseSecurityActionsArgs {
* writes the result back into the form. Lifted out of InboundFormModal so
* the modal body stays focused on orchestration.
*/
export function useSecurityActions({ methods, setSaving, messageApi, modal, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
export function useSecurityActions({
methods,
setSaving,
messageApi,
modal,
nodeId,
setScanResult,
setScanning,
}: UseSecurityActionsArgs) {
const { t } = useTranslation();
const setValue = methods.setValue as unknown as (name: string, value: unknown) => void;
const getValues = methods.getValues as unknown as (name?: string) => unknown;
@@ -94,7 +102,9 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
};
const scanRealityTarget = async (allowPrivate = false) => {
const target = ((getValues('streamSettings.realitySettings.target') as string | undefined) ?? '').trim();
const target = (
(getValues('streamSettings.realitySettings.target') as string | undefined) ?? ''
).trim();
if (!target) {
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
return;
@@ -105,7 +115,8 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
* must too a fronting proxy answers a bare target name with its default
* certificate, which then reads as an untrusted target.
*/
const serverNames = (getValues('streamSettings.realitySettings.serverNames') as string[] | undefined) ?? [];
const serverNames =
(getValues('streamSettings.realitySettings.serverNames') as string[] | undefined) ?? [];
const sni = (serverNames.find((n) => typeof n === 'string' && n.trim() !== '') ?? '').trim();
setScanning(true);
try {
@@ -128,7 +139,9 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
if (r.privateTarget && !allowPrivate) {
modal.confirm({
title: t('pages.inbounds.form.scanPrivateConfirmTitle'),
content: t('pages.inbounds.form.scanPrivateConfirmContent', { target: r.target || target }),
content: t('pages.inbounds.form.scanPrivateConfirmContent', {
target: r.target || target,
}),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: () => scanRealityTarget(true),
@@ -163,15 +176,15 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
const randomizeShortIds = () => {
setValue(
'streamSettings.realitySettings.shortIds',
RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
RandomUtil.randomShortIds()
.split(',')
.map((s) => s.trim())
.filter(Boolean),
);
};
const randomizeSpiderX = () => {
setValue(
'streamSettings.realitySettings.settings.spiderX',
`/${RandomUtil.randomSeq(15)}`,
);
setValue('streamSettings.realitySettings.settings.spiderX', `/${RandomUtil.randomSeq(15)}`);
};
const getNewEchCert = async () => {
@@ -206,7 +219,9 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
}>;
const first = certs[0];
const certFile = first?.certificateFile?.trim() ?? '';
const certContent = Array.isArray(first?.certificate) ? first.certificate.join('\n').trim() : '';
const certContent = Array.isArray(first?.certificate)
? first.certificate.join('\n').trim()
: '';
if (!certFile && !certContent) {
messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
return;
@@ -220,9 +235,10 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
}
const hashes = (msg.obj as string[] | undefined) ?? [];
if (hashes.length === 0) return;
const current = (getValues(
'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
) as string[] | undefined) ?? [];
const current =
(getValues('streamSettings.tlsSettings.settings.pinnedPeerCertSha256') as
| string[]
| undefined) ?? [];
const merged = Array.from(new Set([...current, ...hashes]));
setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
} finally {
@@ -236,7 +252,9 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
* hold the cert file (a CDN front / external endpoint).
*/
const pinFromRemote = async () => {
const server = ((getValues('streamSettings.tlsSettings.serverName') as string | undefined) ?? '').trim();
const server = (
(getValues('streamSettings.tlsSettings.serverName') as string | undefined) ?? ''
).trim();
if (!server) {
messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
return;
@@ -257,9 +275,10 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
}
const hashes = (msg.obj as string[] | undefined) ?? [];
if (hashes.length === 0) return;
const current = (getValues(
'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
) as string[] | undefined) ?? [];
const current =
(getValues('streamSettings.tlsSettings.settings.pinnedPeerCertSha256') as
| string[]
| undefined) ?? [];
const merged = Array.from(new Set([...current, ...hashes]));
setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
} finally {
@@ -274,9 +293,10 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
* Node-assigned inbounds run on the node, so their cert files must be the
* node's own paths (fetched through the central panel), not this panel's.
*/
const msg = typeof nodeId === 'number'
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
const msg =
typeof nodeId === 'number'
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) {
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
return;
@@ -290,24 +310,15 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
`streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
obj.webCertFile ?? '',
);
setValue(
`streamSettings.tlsSettings.certificates.${certName}.keyFile`,
obj.webKeyFile ?? '',
);
setValue(`streamSettings.tlsSettings.certificates.${certName}.keyFile`, obj.webKeyFile ?? '');
} finally {
setSaving(false);
}
};
const clearCertFiles = (certName: number) => {
setValue(
`streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
'',
);
setValue(
`streamSettings.tlsSettings.certificates.${certName}.keyFile`,
'',
);
setValue(`streamSettings.tlsSettings.certificates.${certName}.certificateFile`, '');
setValue(`streamSettings.tlsSettings.certificates.${certName}.keyFile`, '');
};
const onSecurityChange = async (next: string) => {
@@ -323,7 +334,10 @@ export function useSecurityActions({ methods, setSaving, messageApi, modal, node
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
reality.target = '';
reality.serverNames = [];
reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
reality.shortIds = RandomUtil.randomShortIds()
.split(',')
.map((s) => s.trim())
.filter(Boolean);
cleaned.realitySettings = reality;
}
setValue('streamSettings', cleaned);
@@ -190,7 +190,9 @@
color: var(--ant-color-primary);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--ant-color-primary) 40%, transparent);
transition: background 120ms ease, text-decoration-color 120ms ease;
transition:
background 120ms ease,
text-decoration-color 120ms ease;
}
.link-panel-anchor:hover {
File diff suppressed because it is too large Load Diff
+29 -10
View File
@@ -27,7 +27,11 @@ function readHeader(headers: unknown, name: string): string {
const needle = name.toLowerCase();
if (Array.isArray(headers)) {
for (const h of headers) {
if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) {
if (
h &&
typeof h === 'object' &&
String((h as { name?: string }).name ?? '').toLowerCase() === needle
) {
return String((h as { value?: unknown }).value ?? '');
}
}
@@ -46,20 +50,22 @@ function readHeader(headers: unknown, name: string): string {
function readNetworkHost(stream: Record<string, unknown>, network: string): string | null {
switch (network) {
case 'tcp': {
const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined;
const tcp = stream.tcpSettings as
| { header?: { request?: { headers?: unknown } } }
| undefined;
return readHeader(tcp?.header?.request?.headers, 'host');
}
case 'ws': {
const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined;
return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host');
return ws?.host && ws.host.length > 0 ? ws.host : readHeader(ws?.headers, 'host');
}
case 'httpupgrade': {
const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined;
return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host');
return hu?.host && hu.host.length > 0 ? hu.host : readHeader(hu?.headers, 'host');
}
case 'xhttp': {
const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined;
return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host');
return xh?.host && xh.host.length > 0 ? xh.host : readHeader(xh?.headers, 'host');
}
default:
return null;
@@ -90,13 +96,17 @@ export function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo {
const security = (stream.security as string | undefined) ?? 'none';
const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : [];
const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined;
const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined;
const grpcSettings = stream.grpcSettings as
| { multiMode?: boolean; serviceName?: string }
| undefined;
let serverName = '';
if (security === 'tls') {
const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined;
serverName = tls?.sni ?? tls?.serverName ?? '';
} else if (security === 'reality') {
const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined;
const reality = stream.realitySettings as
| { serverNames?: string[]; serverName?: string }
| undefined;
if (Array.isArray(reality?.serverNames)) {
serverName = reality.serverNames.join(', ');
} else if (reality?.serverName) {
@@ -158,7 +168,12 @@ export function statsColor(stats: ClientStats, trafficDiff: number) {
export function formatIpInfo(record: unknown) {
if (record == null) return '';
if (typeof record === 'string' || typeof record === 'number') return String(record);
const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string };
const r = record as {
ip?: string;
IP?: string;
timestamp?: number | string;
Timestamp?: number | string;
};
const ip = r.ip || r.IP || '';
const ts = r.timestamp || r.Timestamp || 0;
if (!ip) return String(record);
@@ -166,8 +181,12 @@ export function formatIpInfo(record: unknown) {
const date = new Date(Number(ts) * 1000);
const timeStr = date
.toLocaleString('en-GB', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(',', '');
+119 -67
View File
@@ -80,11 +80,12 @@ export default function InboundList({
else if (nodeFilter !== 'all') list = list.filter((ib) => ib.nodeId === nodeFilter);
const q = searchKey.trim().toLowerCase();
if (!q) return list;
return list.filter((ib) => (
(ib.remark || '').toLowerCase().includes(q)
|| String(ib.port).includes(q)
|| (ib.protocol || '').toLowerCase().includes(q)
));
return list.filter(
(ib) =>
(ib.remark || '').toLowerCase().includes(q) ||
String(ib.port).includes(q) ||
(ib.protocol || '').toLowerCase().includes(q),
);
}, [dbInbounds, nodeFilter, searchKey]);
const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
@@ -113,17 +114,23 @@ export default function InboundList({
const toggleSelect = useCallback((id: number, checked: boolean) => {
setSelectedRowKeys((prev) => {
const next = new Set(prev);
if (checked) next.add(id); else next.delete(id);
if (checked) next.add(id);
else next.delete(id);
return Array.from(next);
});
}, []);
const selectAll = useCallback((checked: boolean) => {
setSelectedRowKeys(checked ? visibleInbounds.map((i) => i.id) : []);
}, [visibleInbounds]);
const selectAll = useCallback(
(checked: boolean) => {
setSelectedRowKeys(checked ? visibleInbounds.map((i) => i.id) : []);
},
[visibleInbounds],
);
const allSelected = visibleInbounds.length > 0 && selectedRowKeys.length === visibleInbounds.length;
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < visibleInbounds.length;
const allSelected =
visibleInbounds.length > 0 && selectedRowKeys.length === visibleInbounds.length;
const someSelected =
selectedRowKeys.length > 0 && selectedRowKeys.length < visibleInbounds.length;
const handleBulkDelete = useCallback(async () => {
const ok = await onBulkDelete(selectedRowKeys);
@@ -159,9 +166,19 @@ export default function InboundList({
{ key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
{ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
...(subEnable
? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')}${t('pages.settings.subSettings')}` }]
? [
{
key: 'subs',
icon: <ExportOutlined />,
label: `${t('pages.inbounds.export')}${t('pages.settings.subSettings')}`,
},
]
: []),
{ key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
{
key: 'resetInbounds',
icon: <ReloadOutlined />,
label: t('pages.inbounds.resetAllTraffic'),
},
],
onClick: ({ key }) => onGeneralAction(key as GeneralAction),
};
@@ -169,13 +186,22 @@ export default function InboundList({
return (
<Card
hoverable
title={(
title={
<Space>
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />} aria-label={t('pages.inbounds.addInbound')}>
<Button
type="primary"
onClick={onAddInbound}
icon={<PlusOutlined />}
aria-label={t('pages.inbounds.addInbound')}
>
{!isMobile && t('pages.inbounds.addInbound')}
</Button>
<Dropdown trigger={['click']} menu={generalActionsMenu}>
<Button type="primary" icon={<MenuOutlined />} aria-label={t('pages.inbounds.generalActions')}>
<Button
type="primary"
icon={<MenuOutlined />}
aria-label={t('pages.inbounds.generalActions')}
>
{!isMobile && t('pages.inbounds.generalActions')}
</Button>
</Dropdown>
@@ -201,16 +227,26 @@ export default function InboundList({
/>
{selectedRowKeys.length > 0 && (
<>
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
<Tag
color="blue"
closable
onClose={() => setSelectedRowKeys([])}
style={{ marginInlineEnd: 0 }}
>
{t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
</Tag>
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete} aria-label={t('delete')}>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleBulkDelete}
aria-label={t('delete')}
>
{!isMobile && t('delete')}
</Button>
</>
)}
</Space>
)}
}
>
<Space orientation="vertical" style={{ width: '100%' }}>
{isMobile ? (
@@ -222,57 +258,73 @@ export default function InboundList({
</div>
) : (
<>
<div className="card-bulk-bar">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={(e) => selectAll(e.target.checked)}
>
{t('pages.inbounds.selectAll')}
</Checkbox>
{selectedRowKeys.length > 0 && (
<span className="bulk-count">{selectedRowKeys.length}</span>
)}
</div>
{visibleInbounds.map((record) => (
<div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
<div className="card-head">
<Checkbox
checked={selectedRowKeys.includes(record.id)}
onChange={(e) => toggleSelect(record.id, e.target.checked)}
/>
<span className="card-id">#{record.id}</span>
<span className="tag-name">{record.remark}</span>
<div className="card-actions">
<Tooltip title={t('pages.inbounds.inboundInfo')}>
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('pages.inbounds.inboundInfo')}
onClick={() => setStatsRecord(record)}
onKeyDown={activateOnKey(() => setStatsRecord(record))}
/>
</Tooltip>
<Switch
checked={record.enable}
size="small"
onChange={(next) => onSwitchEnable(record, next)}
<div className="card-bulk-bar">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={(e) => selectAll(e.target.checked)}
>
{t('pages.inbounds.selectAll')}
</Checkbox>
{selectedRowKeys.length > 0 && (
<span className="bulk-count">{selectedRowKeys.length}</span>
)}
</div>
{visibleInbounds.map((record) => (
<div
key={record.id}
className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}
>
<div className="card-head">
<Checkbox
checked={selectedRowKeys.includes(record.id)}
onChange={(e) => toggleSelect(record.id, e.target.checked)}
/>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: buildRowActionsMenu({ record, subEnable, t, isMobile: true, hasClients: (clientCount[record.id]?.clients || 0) > 0 }),
onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
}}
>
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
<span className="card-id">#{record.id}</span>
<span className="tag-name">{record.remark}</span>
<div className="card-actions">
<Tooltip title={t('pages.inbounds.inboundInfo')}>
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('pages.inbounds.inboundInfo')}
onClick={() => setStatsRecord(record)}
onKeyDown={activateOnKey(() => setStatsRecord(record))}
/>
</Tooltip>
<Switch
checked={record.enable}
size="small"
onChange={(next) => onSwitchEnable(record, next)}
/>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: buildRowActionsMenu({
record,
subEnable,
t,
isMobile: true,
hasClients: (clientCount[record.id]?.clients || 0) > 0,
}),
onClick: ({ key }) =>
onRowAction({ key: key as RowAction, dbInbound: record }),
}}
>
<Button
type="text"
size="small"
className="row-action-trigger"
icon={<MoreOutlined />}
aria-label={t('more')}
/>
</Dropdown>
</div>
</div>
</div>
</div>
))}
))}
</>
)}
</div>
@@ -17,7 +17,11 @@ interface InboundSpeedTagProps {
}
// Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
export function InboundSpeedTag({ speed, withTooltip = false, tableCell = false }: InboundSpeedTagProps) {
export function InboundSpeedTag({
speed,
withTooltip = false,
tableCell = false,
}: InboundSpeedTagProps) {
const tag = (
<Tag
color="blue"
@@ -25,19 +29,18 @@ export function InboundSpeedTag({ speed, withTooltip = false, tableCell = false
style={tableCell ? SPEED_TAG_STYLE : undefined}
>
{SizeFormatter.speedFormat(speed.up)}
{' / '}
{SizeFormatter.speedFormat(speed.down)}
{' / '} {SizeFormatter.speedFormat(speed.down)}
</Tag>
);
if (!withTooltip) return tag;
return (
<Tooltip
title={(
title={
<div>
<div> {SizeFormatter.speedFormat(speed.up)}</div>
<div> {SizeFormatter.speedFormat(speed.down)}</div>
</div>
)}
}
>
{tag}
</Tooltip>
@@ -55,36 +55,32 @@ export default function InboundStatsModal({
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.protocol')}</span>
<Tag color="purple">{record.protocol}</Tag>
{(record.isWireguard || record.isHysteria) && (
<Tag color="green">UDP</Tag>
)}
{record.isSS && (() => {
const stream = readStreamHints(record.streamSettings);
return (
<>
<Tag color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>
{stream.isTls && <Tag color="blue">TLS</Tag>}
</>
);
})()}
{record.isTunnel && (
<Tag color="green">{tunnelNetworkLabel(record.settings)}</Tag>
)}
{record.isMixed && (
<Tag color="green">{mixedNetworkLabel(record.settings)}</Tag>
)}
{(record.isVMess || record.isVLess || record.isTrojan) && (() => {
const stream = readStreamHints(record.streamSettings);
const l4 = networkL4(stream.network);
return (
<>
<Tag color="green">{networkLabel(stream.network)}</Tag>
{l4 && <Tag color="green">{l4}</Tag>}
{stream.isTls && <Tag color="blue">TLS</Tag>}
{stream.isReality && <Tag color="blue">Reality</Tag>}
</>
);
})()}
{(record.isWireguard || record.isHysteria) && <Tag color="green">UDP</Tag>}
{record.isSS &&
(() => {
const stream = readStreamHints(record.streamSettings);
return (
<>
<Tag color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>
{stream.isTls && <Tag color="blue">TLS</Tag>}
</>
);
})()}
{record.isTunnel && <Tag color="green">{tunnelNetworkLabel(record.settings)}</Tag>}
{record.isMixed && <Tag color="green">{mixedNetworkLabel(record.settings)}</Tag>}
{(record.isVMess || record.isVLess || record.isTrojan) &&
(() => {
const stream = readStreamHints(record.streamSettings);
const l4 = networkL4(stream.network);
return (
<>
<Tag color="green">{networkLabel(stream.network)}</Tag>
{l4 && <Tag color="green">{l4}</Tag>}
{stream.isTls && <Tag color="blue">TLS</Tag>}
{stream.isReality && <Tag color="blue">Reality</Tag>}
</>
);
})()}
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.port')}</span>
@@ -107,8 +103,7 @@ export default function InboundStatsModal({
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.traffic')}</span>
<Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
{SizeFormatter.sizeFormat(record.up + record.down)} /
{' '}
{SizeFormatter.sizeFormat(record.up + record.down)} /{' '}
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
</Tag>
</div>
@@ -125,15 +120,23 @@ export default function InboundStatsModal({
{clientCount[record.id] && (
<div className="stat-row">
<span className="stat-label">{t('clients')}</span>
<Tag color="green" className="client-count-tag">{clientCount[record.id].clients}</Tag>
<Tag color="green" className="client-count-tag">
{clientCount[record.id].clients}
</Tag>
{clientCount[record.id].online.length > 0 && (
<Tag color="blue">{clientCount[record.id].online.length} {t('online')}</Tag>
<Tag color="blue">
{clientCount[record.id].online.length} {t('online')}
</Tag>
)}
{clientCount[record.id].depleted.length > 0 && (
<Tag color="red">{clientCount[record.id].depleted.length} {t('depleted')}</Tag>
<Tag color="red">
{clientCount[record.id].depleted.length} {t('depleted')}
</Tag>
)}
{clientCount[record.id].expiring.length > 0 && (
<Tag color="orange">{clientCount[record.id].expiring.length} {t('depletingSoon')}</Tag>
<Tag color="orange">
{clientCount[record.id].expiring.length} {t('depletingSoon')}
</Tag>
)}
</div>
)}
@@ -144,7 +147,9 @@ export default function InboundStatsModal({
{IntlUtil.formatRelativeTime(record.expiryTime)}
</Tag>
) : (
<Tag color="purple"><InfinityIcon /></Tag>
<Tag color="purple">
<InfinityIcon />
</Tag>
)}
</div>
</div>
+69 -11
View File
@@ -26,7 +26,19 @@ interface RowActionsMenuProps {
isMobile?: boolean;
}
export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean; hasClients?: boolean }): MenuProps['items'] {
export function buildRowActionsMenu({
record,
subEnable,
t,
isMobile,
hasClients,
}: {
record: DBInboundRecord;
subEnable: boolean;
t: (k: string) => string;
isMobile?: boolean;
hasClients?: boolean;
}): MenuProps['items'] {
const items: MenuProps['items'] = [];
if (isMobile) {
items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
@@ -44,20 +56,53 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
});
}
} else {
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
items.push({
key: 'showInfo',
icon: <InfoCircleOutlined />,
label: t('pages.inbounds.inboundInfo'),
});
}
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
items.push({
key: 'clipboard',
icon: <CopyOutlined />,
label: t('pages.inbounds.exportInbound'),
});
items.push({
key: 'resetTraffic',
icon: <RetweetOutlined />,
label: t('pages.inbounds.resetTraffic'),
});
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
if (isInboundMultiUser(record)) {
items.push({ key: 'attachExisting', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachExistingClients') });
items.push({
key: 'attachExisting',
icon: <UsergroupAddOutlined />,
label: t('pages.inbounds.attachExistingClients'),
});
}
if (isInboundMultiUser(record) && hasClients) {
items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
items.push({ key: 'addToGroup', icon: <TagsOutlined />, label: t('pages.inbounds.addClientsToGroup') });
items.push({
key: 'attachClients',
icon: <UsergroupAddOutlined />,
label: t('pages.inbounds.attachClients'),
});
items.push({
key: 'detachClients',
icon: <UsergroupDeleteOutlined />,
label: t('pages.inbounds.detachClients'),
});
items.push({
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.inbounds.addClientsToGroup'),
});
items.push({ type: 'divider' });
items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
items.push({
key: 'delAllClients',
icon: <UsergroupDeleteOutlined />,
danger: true,
label: t('pages.inbounds.delAllClients'),
});
} else {
items.push({ type: 'divider' });
}
@@ -69,7 +114,14 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
const { t } = useTranslation();
return (
<div className="action-buttons">
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onClick('edit')} />
<Button
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<EditOutlined />}
aria-label={t('edit')}
onClick={() => onClick('edit')}
/>
<Dropdown
trigger={['click']}
menu={{
@@ -77,7 +129,13 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
onClick: ({ key }) => onClick(key as RowAction),
}}
>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} aria-label={t('more')} />
<Button
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<MoreOutlined />}
aria-label={t('more')}
/>
</Dropdown>
</div>
);
+21 -6
View File
@@ -20,9 +20,12 @@ export function networkLabel(network: string): string {
const n = (network || '').toLowerCase();
if (!n) return 'TCP';
switch (n) {
case 'httpupgrade': return 'HTTPUpgrade';
case 'splithttp': return 'SplitHTTP';
case 'xhttp': return 'XHTTP';
case 'httpupgrade':
return 'HTTPUpgrade';
case 'splithttp':
return 'SplitHTTP';
case 'xhttp':
return 'XHTTP';
}
return n.toUpperCase();
}
@@ -42,7 +45,11 @@ export function networkL4(network: string): 'UDP' | '' {
// the L4 transport list independent of streamSettings. Returns a
// comma-separated label.
export function commaNetworkLabel(raw: string): string {
const parts = (raw || 'tcp').toLowerCase().split(',').map((p) => p.trim()).filter(Boolean);
const parts = (raw || 'tcp')
.toLowerCase()
.split(',')
.map((p) => p.trim())
.filter(Boolean);
if (parts.length === 0) return 'TCP';
return parts.map(networkLabel).join(',');
}
@@ -62,8 +69,16 @@ export function mixedNetworkLabel(settings: unknown): string {
return st.udp ? 'TCP,UDP' : 'TCP';
}
export function readSettings(settings: unknown): { method?: string; network?: string; allowedNetwork?: string } {
return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
export function readSettings(settings: unknown): {
method?: string;
network?: string;
allowedNetwork?: string;
} {
return coerceInboundJsonField(settings) as {
method?: string;
network?: string;
allowedNetwork?: string;
};
}
export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
@@ -10,7 +10,11 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { coerceInboundJsonField } from '@/models/dbinbound';
import { RowActionsCell } from './RowActions';
import { SPEED_COLUMN_WIDTH, SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
import {
SPEED_COLUMN_WIDTH,
SPEED_TAG_CLASS_NAME,
SPEED_TAG_STYLE,
} from '@/components/utility/speedTagStyle';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import {
readStreamHints,
@@ -53,27 +57,24 @@ export function useInboundColumns({
const { datepicker } = useDatepicker();
return useMemo(() => {
const compareText = (a: string | undefined | null, b: string | undefined | null) => (
(a || '').localeCompare(b || '', undefined, { numeric: true, sensitivity: 'base' })
);
const compareText = (a: string | undefined | null, b: string | undefined | null) =>
(a || '').localeCompare(b || '', undefined, { numeric: true, sensitivity: 'base' });
const nodeName = (record: DBInboundRecord) => {
if (record.nodeId == null) return t('pages.inbounds.localPanel');
return nodesById.get(record.nodeId)?.name || `node #${record.nodeId}`;
};
const clientTotal = (record: DBInboundRecord) => (
(clientCount[record.id] || fallbackClientCount(record))?.clients ?? 0
);
const clientTotal = (record: DBInboundRecord) =>
(clientCount[record.id] || fallbackClientCount(record))?.clients ?? 0;
const speedTotal = (record: DBInboundRecord) => {
const speed = inboundSpeed[record.id];
return speed ? speed.up + speed.down : 0;
};
const expirySortValue = (record: DBInboundRecord) => (
record.expiryTime > 0 ? record.expiryTime : Number.MAX_SAFE_INTEGER
);
const expirySortValue = (record: DBInboundRecord) =>
record.expiryTime > 0 ? record.expiryTime : Number.MAX_SAFE_INTEGER;
const fallbackClientCount = (record: DBInboundRecord): ClientCountEntry | null => {
const settings = coerceInboundJsonField(record.settings) as {
@@ -126,10 +127,7 @@ export function useInboundColumns({
align: 'center',
width: 80,
render: (_, record) => (
<Switch
checked={record.enable}
onChange={(next) => onSwitchEnable(record, next)}
/>
<Switch checked={record.enable} onChange={(next) => onSwitchEnable(record, next)} />
),
},
];
@@ -160,9 +158,7 @@ export function useInboundColumns({
if (!node) {
return <Tag color="orange">node #{record.nodeId}</Tag>;
}
return (
<Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>
);
return <Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>;
},
});
}
@@ -198,24 +194,68 @@ export function useInboundColumns({
width: 190,
sorter: (a, b) => compareText(a.protocol, b.protocol),
render: (_, record) => {
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
const tags: ReactElement[] = [
<Tag key="p" color="purple">
{record.protocol}
</Tag>,
];
if (record.isWireguard || record.isHysteria) {
tags.push(<Tag key="n" color="green">UDP</Tag>);
tags.push(
<Tag key="n" color="green">
UDP
</Tag>,
);
} else if (record.isSS) {
const stream = readStreamHints(record.streamSettings);
tags.push(<Tag key="n" color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>);
if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
tags.push(
<Tag key="n" color="green">
{shadowsocksNetworkLabel(record.settings)}
</Tag>,
);
if (stream.isTls)
tags.push(
<Tag key="tls" color="blue">
TLS
</Tag>,
);
} else if (record.isTunnel) {
tags.push(<Tag key="n" color="green">{tunnelNetworkLabel(record.settings)}</Tag>);
tags.push(
<Tag key="n" color="green">
{tunnelNetworkLabel(record.settings)}
</Tag>,
);
} else if (record.isMixed) {
tags.push(<Tag key="n" color="green">{mixedNetworkLabel(record.settings)}</Tag>);
tags.push(
<Tag key="n" color="green">
{mixedNetworkLabel(record.settings)}
</Tag>,
);
} else if (record.isVMess || record.isVLess || record.isTrojan) {
const stream = readStreamHints(record.streamSettings);
tags.push(<Tag key="n" color="green">{networkLabel(stream.network)}</Tag>);
tags.push(
<Tag key="n" color="green">
{networkLabel(stream.network)}
</Tag>,
);
const l4 = networkL4(stream.network);
if (l4) tags.push(<Tag key="l4" color="green">{l4}</Tag>);
if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
if (stream.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
if (l4)
tags.push(
<Tag key="l4" color="green">
{l4}
</Tag>,
);
if (stream.isTls)
tags.push(
<Tag key="tls" color="blue">
TLS
</Tag>,
);
if (stream.isReality)
tags.push(
<Tag key="reality" color="blue">
Reality
</Tag>,
);
}
return <div className="protocol-tags">{tags}</div>;
},
@@ -231,57 +271,97 @@ export function useInboundColumns({
if (!cc) return null;
return (
<>
<Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>
<Tag
className="client-count-tag"
style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
>
<TeamOutlined /> {cc.clients}
</Tag>
{cc.active.length > 0 ? (
<Popover
title={t('subscription.active')}
content={(
content={
<div className="client-email-list">
{cc.active.map((e) => <div key={e}>{e}</div>)}
{cc.active.map((e) => (
<div key={e}>{e}</div>
))}
</div>
)}
}
>
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.active.length}</Tag>
<Tag
color="green"
className="client-count-tag"
style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
>
{cc.active.length}
</Tag>
</Popover>
) : (
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>0</Tag>
<Tag
color="green"
className="client-count-tag"
style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
>
0
</Tag>
)}
{cc.deactive.length > 0 && (
<Popover
title={t('disabled')}
content={(
content={
<div className="client-email-list">
{cc.deactive.map((e) => <div key={e}>{e}</div>)}
{cc.deactive.map((e) => (
<div key={e}>{e}</div>
))}
</div>
)}
}
>
<Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.deactive.length}</Tag>
<Tag
className="client-count-tag"
style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
>
{cc.deactive.length}
</Tag>
</Popover>
)}
{cc.depleted.length > 0 && (
<Popover
title={t('depleted')}
content={(
content={
<div className="client-email-list">
{cc.depleted.map((e) => <div key={e}>{e}</div>)}
{cc.depleted.map((e) => (
<div key={e}>{e}</div>
))}
</div>
)}
}
>
<Tag color="red" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.depleted.length}</Tag>
<Tag
color="red"
className="client-count-tag"
style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
>
{cc.depleted.length}
</Tag>
</Popover>
)}
{cc.online.length > 0 && (
<Popover
title={t('online')}
content={(
content={
<div className="client-email-list">
{cc.online.map((e) => <div key={e}>{e}</div>)}
{cc.online.map((e) => (
<div key={e}>{e}</div>
))}
</div>
)}
}
>
<Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.online.length}</Tag>
<Tag
color="blue"
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{cc.online.length}
</Tag>
</Popover>
)}
</>
@@ -293,10 +373,10 @@ export function useInboundColumns({
key: 'traffic',
align: 'center',
width: 140,
sorter: (a, b) => (a.up + a.down) - (b.up + b.down),
sorter: (a, b) => a.up + a.down - (b.up + b.down),
render: (_, record) => (
<Popover
content={(
content={
<table cellPadding={2}>
<tbody>
<tr>
@@ -311,11 +391,10 @@ export function useInboundColumns({
)}
</tbody>
</table>
)}
}
>
<Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
{SizeFormatter.sizeFormat(record.up + record.down)} /
{' '}
{SizeFormatter.sizeFormat(record.up + record.down)} /{' '}
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
</Tag>
</Popover>
@@ -330,7 +409,11 @@ export function useInboundColumns({
render: (_, record) => {
const speed = inboundSpeed[record.id];
if (!isActiveSpeed(speed)) {
return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}></Tag>;
return (
<Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>
</Tag>
);
}
return <InboundSpeedTag speed={speed} withTooltip tableCell />;
},
@@ -345,17 +428,38 @@ export function useInboundColumns({
if (record.expiryTime > 0) {
return (
<Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)} style={{ minWidth: 50 }}>
<Tag
color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)}
style={{ minWidth: 50 }}
>
{IntlUtil.formatRelativeTime(record.expiryTime)}
</Tag>
</Popover>
);
}
return <Tag color="purple"><InfinityIcon /></Tag>;
return (
<Tag color="purple">
<InfinityIcon />
</Tag>
);
},
},
);
return cols;
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, inboundSpeed, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}, [
t,
hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
subEnable,
expireDiff,
trafficDiff,
datepicker,
onRowAction,
onSwitchEnable,
]);
}
+36 -16
View File
@@ -57,7 +57,10 @@ export default function QrCodeModal({
useEffect(() => {
if (!open || !dbInbound) return;
const inbound = inboundFromDb(dbInbound);
const fallbackHostname = preferPublicHost(window.location.hostname, subSettings?.publicHost ?? '');
const fallbackHostname = preferPublicHost(
window.location.hostname,
subSettings?.publicHost ?? '',
);
if (inbound.protocol === Protocols.WIREGUARD) {
const peerRemark = client?.email
? `${dbInbound.remark}-${client.email}`
@@ -110,7 +113,11 @@ export default function QrCodeModal({
items.push({ key: 'sub', header: t('subscription.title'), value: subLink });
}
if (subJsonLink) {
items.push({ key: 'sub-json', header: `${t('subscription.title')} (JSON)`, value: subJsonLink });
items.push({
key: 'sub-json',
header: `${t('subscription.title')} (JSON)`,
value: subJsonLink,
});
}
links.forEach((link, idx) => {
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
@@ -123,25 +130,31 @@ export default function QrCodeModal({
downloadName: `peer-${idx + 1}.conf`,
});
if (wireguardLinks[idx]) {
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx], showQr: false });
items.push({
key: `wl${idx}`,
header: `Peer ${idx + 1} link`,
value: wireguardLinks[idx],
showQr: false,
});
}
});
return items;
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
const collapseItems: CollapseProps['items'] = useMemo(
() => qrItems.map((item) => ({
key: item.key,
label: item.header,
children: (
<QrPanel
value={item.value}
remark={item.header}
downloadName={item.downloadName || ''}
showQr={item.showQr !== false && !isPostQuantumLink(item.value)}
/>
),
})),
() =>
qrItems.map((item) => ({
key: item.key,
label: item.header,
children: (
<QrPanel
value={item.value}
remark={item.header}
downloadName={item.downloadName || ''}
showQr={item.showQr !== false && !isPostQuantumLink(item.value)}
/>
),
})),
[qrItems],
);
@@ -154,7 +167,14 @@ export default function QrCodeModal({
}, [open, qrItems]);
return (
<Modal open={open} onCancel={onClose} title={t('qrCode')} footer={null} width={420} destroyOnHidden>
<Modal
open={open}
onCancel={onClose}
title={t('qrCode')}
footer={null}
width={420}
destroyOnHidden
>
{dbInbound && collapseItems && collapseItems.length > 0 && (
<Collapse
ghost
+19 -4
View File
@@ -38,7 +38,10 @@ async function svgToPngBlob(svgEl: SVGSVGElement | null, size: number): Promise<
URL.revokeObjectURL(url);
canvas.toBlob((blob) => resolve(blob), 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
img.src = url;
});
}
@@ -95,18 +98,30 @@ export default function QrPanel({
<div className="qr-panel">
{messageContextHolder}
<div className="qr-panel-header">
<Tag color="green" className="qr-remark">{remark}</Tag>
<Tag color="green" className="qr-remark">
{remark}
</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
</Tooltip>
{showQr && (
<Tooltip title={t('downloadImage')}>
<Button size="small" icon={<PictureOutlined />} aria-label={t('downloadImage')} onClick={downloadImage} />
<Button
size="small"
icon={<PictureOutlined />}
aria-label={t('downloadImage')}
onClick={downloadImage}
/>
</Tooltip>
)}
{downloadName && (
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} onClick={download} />
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
onClick={download}
/>
</Tooltip>
)}
</div>
+126 -42
View File
@@ -35,7 +35,10 @@ type DBInboundInstance = InstanceType<typeof DBInbound>;
// while recent, so returning to the page shows the last throughput immediately
// and the next poll refreshes it.
const SPEED_CACHE_TTL_MS = 15000;
let inboundSpeedCache: { at: number; data: Record<number, InboundSpeedEntry> } = { at: 0, data: {} };
let inboundSpeedCache: { at: number; data: Record<number, InboundSpeedEntry> } = {
at: 0,
data: {},
};
interface TrafficDelta {
Tag: string;
@@ -86,7 +89,9 @@ async function fetchOnlineClientsByGuid(): Promise<Record<string, string[]>> {
const msg = await HttpUtil.post('/panel/api/clients/onlinesByGuid', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByGuid');
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByGuid');
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
return validated.obj && typeof validated.obj === 'object'
? (validated.obj as Record<string, string[]>)
: {};
}
// Inbound tags that carried traffic recently, grouped by node (local = key 0).
@@ -97,7 +102,9 @@ async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
const msg = await HttpUtil.post('/panel/api/clients/activeInbounds', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch activeInbounds');
const validated = parseMsg(msg, ActiveInboundsByNodeSchema, 'clients/activeInbounds');
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
return validated.obj && typeof validated.obj === 'object'
? (validated.obj as Record<string, string[]>)
: {};
}
function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string>> {
@@ -113,11 +120,13 @@ async function fetchLastOnlineMap(): Promise<Record<string, number>> {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
const validated = parseMsg(msg, LastOnlineMapSchema, 'clients/lastOnline');
return (validated.obj && typeof validated.obj === 'object') ? validated.obj : {};
return validated.obj && typeof validated.obj === 'object' ? validated.obj : {};
}
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, {
silent: true,
});
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj ?? {};
@@ -170,14 +179,25 @@ export function useInbounds() {
const pageSize = defaults.pageSize ?? 0;
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
const subSettings: SubSettings = useMemo(() => ({
enable: !!defaults.subEnable,
subTitle: defaults.subTitle || '',
subURI: defaults.subURI || '',
subJsonURI: defaults.subJsonURI || '',
subJsonEnable: !!defaults.subJsonEnable,
publicHost: defaults.subDomain || defaults.webDomain || '',
}), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable, defaults.subDomain, defaults.webDomain]);
const subSettings: SubSettings = useMemo(
() => ({
enable: !!defaults.subEnable,
subTitle: defaults.subTitle || '',
subURI: defaults.subURI || '',
subJsonURI: defaults.subJsonURI || '',
subJsonEnable: !!defaults.subJsonEnable,
publicHost: defaults.subDomain || defaults.webDomain || '',
}),
[
defaults.subEnable,
defaults.subTitle,
defaults.subURI,
defaults.subJsonURI,
defaults.subJsonEnable,
defaults.subDomain,
defaults.webDomain,
],
);
useEffect(() => {
if (defaults.datepicker) setDatepicker(datepicker);
@@ -224,9 +244,22 @@ export function useInbounds() {
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
const rollupClients = useCallback(
(dbInbound: DBInboundInstance, inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] }): ClientRollup => {
(
dbInbound: DBInboundInstance,
inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] },
): ClientRollup => {
const clientStats = Array.isArray((dbInbound as { clientStats?: unknown }).clientStats)
? (dbInbound as unknown as { clientStats: { email: string; total: number; up: number; down: number; expiryTime: number }[] }).clientStats
? (
dbInbound as unknown as {
clientStats: {
email: string;
total: number;
up: number;
down: number;
expiryTime: number;
}[];
}
).clientStats
: [];
const clients = inbound?.clients || [];
const active: string[] = [];
@@ -241,17 +274,22 @@ export function useInbounds() {
// inbound. Local inbounds carry the panel's own GUID (filled server-side);
// a node-managed inbound carries its origin node's GUID, or falls back to
// the master-local synthetic id for an old-build node without one (#4983).
const guid = dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
const guid =
dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
const nodeOnline = onlineByGuidRef.current.get(guid);
// A node absent from the active map reports no per-inbound activity, so
// leave its inbounds ungated. When present, only mark a client online on
// this inbound if its tag actually carried traffic — that's what stops a
// multi-inbound client lighting up every inbound it's attached to.
const activeForNode = activeByGuidRef.current.get(guid);
const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
const inboundActive =
activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
if (dbInbound.enable) {
const statsByEmail = new Map<string, { email: string; total: number; up: number; down: number; expiryTime: number }>();
const statsByEmail = new Map<
string,
{ email: string; total: number; up: number; down: number; expiryTime: number }
>();
for (const stats of clientStats) {
if (stats.email) statsByEmail.set(stats.email.toLowerCase(), stats);
}
@@ -259,7 +297,8 @@ export function useInbounds() {
if (client.comment && client.email) comments.set(client.email, client.comment);
if (!client.email) continue;
const stats = statsByEmail.get(client.email.toLowerCase());
const exhausted = stats != null && stats.total > 0 && stats.up + stats.down >= stats.total;
const exhausted =
stats != null && stats.total > 0 && stats.up + stats.down >= stats.total;
const expired = stats != null && stats.expiryTime > 0 && stats.expiryTime <= now;
if (expired || exhausted) {
depleted.push(client.email);
@@ -326,7 +365,11 @@ export function useInbounds() {
method?: string;
clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
};
if (row.protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol: row.protocol, settings })) continue;
if (
row.protocol === Protocols.SHADOWSOCKS &&
!isSSMultiUser({ protocol: row.protocol, settings })
)
continue;
counts[row.id] = rollupClients(dbInbound, { clients: settings.clients });
}
}
@@ -360,7 +403,9 @@ export function useInbounds() {
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
}, [lastOnlineQuery.data]);
const fetched = (slimQuery.data !== undefined || slimQuery.isError) && (defaultsQuery.data !== undefined || defaultsQuery.isError);
const fetched =
(slimQuery.data !== undefined || slimQuery.isError) &&
(defaultsQuery.data !== undefined || defaultsQuery.isError);
const fetchErrorSource = slimQuery.error || defaultsQuery.error;
const fetchError = fetchErrorSource ? (fetchErrorSource as Error).message : '';
@@ -385,22 +430,25 @@ export function useInbounds() {
// uuid/password/flow/etc.) and swaps it into the cached list. Use this
// before opening edit / info / qr / export / clone flows — refresh() loads
// the slim list which doesn't carry per-client secrets.
const hydrateInbound = useCallback(async (id: number) => {
const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
if (!msg?.success || !msg.obj) return null;
const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
if (!validated.obj) return null;
const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
setDbInbounds((prev) => {
const next = prev.map((row) => (
(row as unknown as { id: number }).id === id ? dbInbound : row
));
dbInboundsRef.current = next;
return next;
});
rebuildClientCount();
return dbInbound;
}, [rebuildClientCount]);
const hydrateInbound = useCallback(
async (id: number) => {
const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
if (!msg?.success || !msg.obj) return null;
const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
if (!validated.obj) return null;
const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
setDbInbounds((prev) => {
const next = prev.map((row) =>
(row as unknown as { id: number }).id === id ? dbInbound : row,
);
dbInboundsRef.current = next;
return next;
});
rebuildClientCount();
return dbInbound;
},
[rebuildClientCount],
);
const applyTrafficEvent = useCallback(
(payload: unknown) => {
@@ -470,19 +518,34 @@ export function useInbounds() {
if (!payload || typeof payload !== 'object') return;
const p = payload as {
inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
clients?: { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }[];
clients?: {
email: string;
up?: number;
down?: number;
total?: number;
expiryTime?: number;
enable?: boolean;
}[];
};
let touched = false;
if (Array.isArray(p.inbounds) && p.inbounds.length > 0) {
const byId = new Map<number, { id: number; up?: number; down?: number; total?: number; enable?: boolean }>();
const byId = new Map<
number,
{ id: number; up?: number; down?: number; total?: number; enable?: boolean }
>();
for (const row of p.inbounds) {
if (row && row.id != null) byId.set(row.id, row);
}
for (const ib of dbInboundsRef.current) {
const upd = byId.get((ib as unknown as { id: number }).id);
if (!upd) continue;
const ibRec = ib as unknown as { up: number; down: number; total: number; enable: boolean };
const ibRec = ib as unknown as {
up: number;
down: number;
total: number;
enable: boolean;
};
if (typeof upd.up === 'number') ibRec.up = upd.up;
if (typeof upd.down === 'number') ibRec.down = upd.down;
if (typeof upd.total === 'number') ibRec.total = upd.total;
@@ -492,12 +555,33 @@ export function useInbounds() {
}
if (Array.isArray(p.clients) && p.clients.length > 0) {
const byEmail = new Map<string, { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }>();
const byEmail = new Map<
string,
{
email: string;
up?: number;
down?: number;
total?: number;
expiryTime?: number;
enable?: boolean;
}
>();
for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
for (const ib of dbInboundsRef.current) {
const stats = (ib as unknown as { clientStats: { email: string; up: number; down: number; total: number; expiryTime: number; enable: boolean }[] }).clientStats;
const stats = (
ib as unknown as {
clientStats: {
email: string;
up: number;
down: number;
total: number;
expiryTime: number;
enable: boolean;
}[];
}
).clientStats;
if (!Array.isArray(stats)) continue;
for (let i = 0; i < stats.length; i++) {
const stat = stats[i];
+35 -13
View File
@@ -18,7 +18,12 @@ interface BackupModalProps {
onBusy: (e: BusyEvent) => void;
}
export default function BackupModal({ open, basePath: _basePath, onClose, onBusy }: BackupModalProps) {
export default function BackupModal({
open,
basePath: _basePath,
onClose,
onBusy,
}: BackupModalProps) {
const { t } = useTranslation();
const isPostgres = window.X_UI_DB_TYPE === 'postgres';
const [keepHostSettings, setKeepHostSettings] = useState(true);
@@ -67,12 +72,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
}
return (
<Modal
open={open}
title={t('pages.index.backupTitle')}
footer={null}
onCancel={onClose}
>
<Modal open={open} title={t('pages.index.backupTitle')} footer={null} onCancel={onClose}>
{isPostgres && (
<div className="backup-description" style={{ marginBottom: 16 }}>
{t('pages.index.backupPostgresNote')}
@@ -83,10 +83,17 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
<div className="backup-meta">
<div className="backup-title">{t('pages.index.exportDatabase')}</div>
<div className="backup-description">
{isPostgres ? t('pages.index.exportDatabasePgDesc') : t('pages.index.exportDatabaseDesc')}
{isPostgres
? t('pages.index.exportDatabasePgDesc')
: t('pages.index.exportDatabaseDesc')}
</div>
</div>
<Button type="primary" aria-label={t('pages.index.exportDatabase')} onClick={exportDb} icon={<DownloadOutlined />} />
<Button
type="primary"
aria-label={t('pages.index.exportDatabase')}
onClick={exportDb}
icon={<DownloadOutlined />}
/>
</div>
{isPostgres && (
@@ -95,7 +102,12 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
<div className="backup-title">{t('pages.index.migrationDownload')}</div>
<div className="backup-description">{t('pages.index.migrationDownloadPgDesc')}</div>
</div>
<Button type="primary" aria-label={t('pages.index.migrationDownload')} onClick={exportMigration} icon={<DownloadOutlined />} />
<Button
type="primary"
aria-label={t('pages.index.migrationDownload')}
onClick={exportMigration}
icon={<DownloadOutlined />}
/>
</div>
)}
@@ -103,15 +115,25 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
<div className="backup-meta">
<div className="backup-title">{t('pages.index.importDatabase')}</div>
<div className="backup-description">
{isPostgres ? t('pages.index.importDatabasePgDesc') : t('pages.index.importDatabaseDesc')}
{isPostgres
? t('pages.index.importDatabasePgDesc')
: t('pages.index.importDatabaseDesc')}
</div>
</div>
<Button type="primary" aria-label={t('pages.index.importDatabase')} onClick={importDb} icon={<UploadOutlined />} />
<Button
type="primary"
aria-label={t('pages.index.importDatabase')}
onClick={importDb}
icon={<UploadOutlined />}
/>
</div>
<div className="backup-item">
<div className="backup-meta">
<Checkbox checked={keepHostSettings} onChange={(e) => setKeepHostSettings(e.target.checked)}>
<Checkbox
checked={keepHostSettings}
onChange={(e) => setKeepHostSettings(e.target.checked)}
>
{t('pages.index.importKeepHostSettings')}
</Checkbox>
<div className="backup-description">{t('pages.index.importKeepHostSettingsDesc')}</div>
+7 -1
View File
@@ -13,7 +13,13 @@ interface ConnectionsCardProps {
isMobile: boolean;
}
export default function ConnectionsCard({ status, tcp, udp, labels, isMobile }: ConnectionsCardProps) {
export default function ConnectionsCard({
status,
tcp,
udp,
labels,
isMobile,
}: ConnectionsCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
+27 -10
View File
@@ -68,8 +68,14 @@
}
@keyframes ovPulse {
0% { transform: scale(0.9); opacity: 0.5; }
100% { transform: scale(2.4); opacity: 0; }
0% {
transform: scale(0.9);
opacity: 0.5;
}
100% {
transform: scale(2.4);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -195,11 +201,15 @@
}
@media (max-width: 1100px) {
.ov-vitals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ov-vitals {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.ov-vitals { grid-template-columns: minmax(0, 1fr); }
.ov-vitals {
grid-template-columns: minmax(0, 1fr);
}
}
.ov-tile {
@@ -270,7 +280,9 @@
}
@media (max-width: 1100px) {
.ov-mid { grid-template-columns: minmax(0, 1fr); }
.ov-mid {
grid-template-columns: minmax(0, 1fr);
}
}
.ov-wide-head {
@@ -388,22 +400,27 @@
uptime (xray | os) · panel (memory | threads) · ip addresses */
.ov-strip-grid {
display: grid;
grid-template-columns:
minmax(max-content, 1.2fr) minmax(max-content, 1.2fr) minmax(0, 1.6fr);
grid-template-columns: minmax(max-content, 1.2fr) minmax(max-content, 1.2fr) minmax(0, 1.6fr);
gap: 16px;
padding: var(--ov-pad);
}
@media (max-width: 1439px) {
.ov-strip-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ov-strip-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.ov-strip-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ov-strip-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.ov-strip-grid { grid-template-columns: minmax(0, 1fr); }
.ov-strip-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (min-width: 1440px) {
+26 -15
View File
@@ -11,7 +11,12 @@ import {
} from '@ant-design/icons';
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
import { USAGE_CRIT_COLOR, USAGE_CRIT_PERCENT, USAGE_WARN_COLOR, USAGE_WARN_PERCENT } from '@/models/status';
import {
USAGE_CRIT_COLOR,
USAGE_CRIT_PERCENT,
USAGE_WARN_COLOR,
USAGE_WARN_PERCENT,
} from '@/models/status';
import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -41,7 +46,9 @@ export default function IndexPage() {
const { status, fetched, fetchError, refresh } = useStatusQuery();
const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [accessLogEnable, setAccessLogEnable] = useState(false);
const [devChannelEnable, setDevChannelEnable] = useState(false);
@@ -87,13 +94,10 @@ export default function IndexPage() {
[panelUpdateInfo.currentVersion],
);
const setBusy = useCallback(
({ busy, tip }: { busy: boolean; tip?: string }) => {
setLoading(busy);
if (tip) setLoadingTip(tip);
},
[],
);
const setBusy = useCallback(({ busy, tip }: { busy: boolean; tip?: string }) => {
setLoading(busy);
if (tip) setLoadingTip(tip);
}, []);
const stopXray = useCallback(async () => {
await HttpUtil.post('/panel/api/server/stopXrayService');
@@ -147,9 +151,14 @@ export default function IndexPage() {
];
const list = (xs: typeof items) => xs.map((i) => `${i.name} ${i.value.toFixed(0)}%`).join(', ');
const crit = items.filter((i) => i.value >= USAGE_CRIT_PERCENT);
if (crit.length) return { text: t('pages.index.healthCritical', { list: list(crit) }), color: USAGE_CRIT_COLOR };
if (crit.length)
return {
text: t('pages.index.healthCritical', { list: list(crit) }),
color: USAGE_CRIT_COLOR,
};
const warm = items.filter((i) => i.value >= USAGE_WARN_PERCENT);
if (warm.length) return { text: t('pages.index.healthWarm', { list: list(warm) }), color: USAGE_WARN_COLOR };
if (warm.length)
return { text: t('pages.index.healthWarm', { list: list(warm) }), color: USAGE_WARN_COLOR };
return null;
}, [status, t]);
@@ -174,7 +183,11 @@ export default function IndexPage() {
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
extra={
<Button type="primary" onClick={refresh}>
{t('refresh')}
</Button>
}
/>
) : (
<div className="ov-page">
@@ -329,9 +342,7 @@ export default function IndexPage() {
open={configTextOpen}
title={t('pages.index.config')}
width={isMobile ? '100%' : 900}
style={isMobile
? { top: 20, maxWidth: 'calc(100vw - 16px)' }
: { top: 20 }}
style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : { top: 20 }}
onCancel={() => setConfigTextOpen(false)}
footer={[
<Button
+1 -1
View File
@@ -151,7 +151,7 @@ body.dark .log-container {
--log-divider: rgba(255, 255, 255, 0.1);
}
html[data-theme="ultra-dark"] .log-container {
html[data-theme='ultra-dark'] .log-container {
--log-stamp: #7fb6f1;
--log-debug: #7fb6f1;
--log-info: #5fd9b0;
+18 -3
View File
@@ -72,7 +72,15 @@ export default function LogModal({ open, onClose }: LogModalProps) {
const titleNode = (
<>
{t('pages.index.logs')}
<SyncOutlined spin={loading} className="reload-icon" role="button" tabIndex={0} aria-label={t('refresh')} onClick={refresh} onKeyDown={activateOnKey(refresh)} />
<SyncOutlined
spin={loading}
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
);
@@ -126,7 +134,12 @@ export default function LogModal({ open, onClose }: LogModalProps) {
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button type="primary" onClick={download} icon={<DownloadOutlined />} aria-label={t('download')} />
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</Form.Item>
</Form>
@@ -162,7 +175,9 @@ export default function LogModal({ open, onClose }: LogModalProps) {
<div key={idx} className="log-line">
{log.stamp && <span className="log-stamp">{log.stamp}</span>}
{log.stamp && log.levelText ? ' ' : ''}
{log.levelText && <span className={`log-level ${log.levelClass}`}>{log.levelText}</span>}
{log.levelText && (
<span className={`log-level ${log.levelClass}`}>{log.levelText}</span>
)}
{(log.body || log.service) && (
<>
{(log.stamp || log.levelText) && <span> - </span>}
+46 -12
View File
@@ -75,20 +75,58 @@ export default function OverviewActionBar({
const actionGroups: BarAction[][] = [
[
{ key: 'restart', icon: <ReloadOutlined />, text: t('pages.index.restartXray'), onClick: onRestartXray, primary: true },
{ key: 'stop', icon: <PoweroffOutlined />, text: t('pages.index.stopXray'), onClick: onStopXray },
{
key: 'restart',
icon: <ReloadOutlined />,
text: t('pages.index.restartXray'),
onClick: onRestartXray,
primary: true,
},
{
key: 'stop',
icon: <PoweroffOutlined />,
text: t('pages.index.stopXray'),
onClick: onStopXray,
},
],
[
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
...(accessLogEnable
? [{ key: 'accessLogs', icon: <FileTextOutlined />, text: t('pages.index.accessLogs'), onClick: onOpenXrayLogs }]
? [
{
key: 'accessLogs',
icon: <FileTextOutlined />,
text: t('pages.index.accessLogs'),
onClick: onOpenXrayLogs,
},
]
: []),
{ key: 'config', icon: <ControlOutlined />, text: t('pages.index.config'), onClick: onOpenConfig },
{ key: 'backup', icon: <CloudServerOutlined />, text: t('pages.index.backupTitle'), onClick: onOpenBackup },
{
key: 'config',
icon: <ControlOutlined />,
text: t('pages.index.config'),
onClick: onOpenConfig,
},
{
key: 'backup',
icon: <CloudServerOutlined />,
text: t('pages.index.backupTitle'),
onClick: onOpenBackup,
},
],
[
{ key: 'history', icon: <AreaChartOutlined />, text: t('pages.index.systemHistoryTitle'), onClick: onOpenSystemHistory },
{ key: 'metrics', icon: <ArrowUpOutlined />, text: t('pages.index.xrayMetricsTitle'), onClick: onOpenXrayMetrics },
{
key: 'history',
icon: <AreaChartOutlined />,
text: t('pages.index.systemHistoryTitle'),
onClick: onOpenSystemHistory,
},
{
key: 'metrics',
icon: <ArrowUpOutlined />,
text: t('pages.index.xrayMetricsTitle'),
onClick: onOpenXrayMetrics,
},
],
];
@@ -98,11 +136,7 @@ export default function OverviewActionBar({
<span>{`${t('pages.index.xrayStatus')} · ${stateText}`}</span>
{hasVersion && (
<Tooltip title={t('pages.index.xraySwitch')}>
<button
type="button"
className="ov-state-version"
onClick={onOpenVersionSwitch}
>
<button type="button" className="ov-state-version" onClick={onOpenVersionSwitch}>
{`v${status.xray.version}`}
</button>
</Tooltip>
+25 -17
View File
@@ -83,7 +83,10 @@ export default function PanelUpdateModal({
function updatePanel() {
modal.confirm({
title: t('pages.index.panelUpdateDialog'),
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', info.latestVersion || ''),
content: t('pages.index.panelUpdateDialogDesc').replace(
'#version#',
info.latestVersion || '',
),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
@@ -104,8 +107,16 @@ export default function PanelUpdateModal({
return;
}
modal[outcome === 'failed' ? 'error' : 'warning']({
title: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedTitle' : 'pages.index.panelUpdateUnknownTitle'),
content: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedDesc' : 'pages.index.panelUpdateUnknownDesc'),
title: t(
outcome === 'failed'
? 'pages.index.panelUpdateFailedTitle'
: 'pages.index.panelUpdateUnknownTitle',
),
content: t(
outcome === 'failed'
? 'pages.index.panelUpdateFailedDesc'
: 'pages.index.panelUpdateUnknownDesc',
),
okText: t('refresh'),
onOk: () => window.location.reload(),
});
@@ -116,12 +127,7 @@ export default function PanelUpdateModal({
return (
<>
{contextHolder}
<Modal
open={open}
title={t('pages.index.updatePanel')}
footer={null}
onCancel={onClose}
>
<Modal open={open} title={t('pages.index.updatePanel')} footer={null} onCancel={onClose}>
{info.updateAvailable && (
<Alert
type="warning"
@@ -134,11 +140,7 @@ export default function PanelUpdateModal({
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.devChannel')}</span>
<Switch
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
<Switch checked={!!devChannelEnable} loading={channelBusy} onChange={handleChannel} />
</div>
</div>
@@ -153,16 +155,22 @@ export default function PanelUpdateModal({
<div className="version-list">
<div className="version-list-item">
<span>{isDev ? t('pages.index.currentCommit') : t('pages.index.currentPanelVersion')}</span>
<span>
{isDev ? t('pages.index.currentCommit') : t('pages.index.currentPanelVersion')}
</span>
{isDev ? (
<Tag color="green">{info.currentCommit || '?'}</Tag>
) : (
<Tag color="green">{formatPanelVersion(window.X_UI_CUR_VER || info.currentVersion) || '?'}</Tag>
<Tag color="green">
{formatPanelVersion(window.X_UI_CUR_VER || info.currentVersion) || '?'}
</Tag>
)}
</div>
{info.updateAvailable ? (
<div className="version-list-item">
<span>{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}</span>
<span>
{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}
</span>
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
</div>
) : (
@@ -24,7 +24,11 @@
margin: 8px 8px 16px;
padding: 16px 18px 18px;
border-radius: 14px;
background: linear-gradient(180deg, color-mix(in srgb, var(--ant-color-primary) 6%, transparent), transparent);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--ant-color-primary) 6%, transparent),
transparent
);
border: 1px solid var(--ant-color-border-secondary);
box-shadow: 0 2px 12px var(--ant-color-fill-quaternary);
}
+134 -14
View File
@@ -45,15 +45,123 @@ interface MetricDef {
}
const METRICS: MetricDef[] = [
{ key: 'cpu', tab: 'CPU', tabKey: 'pages.index.cpu', title: 'pages.index.historyTitleCpu', icon: <DashboardOutlined />, valueMax: 100, unit: '%', stroke: '' },
{ key: 'mem', tab: 'RAM', tabKey: 'pages.index.memory', title: 'pages.index.historyTitleMem', icon: <DatabaseOutlined />, valueMax: 100, unit: '%', stroke: '#7c4dff', key2: 'swap', stroke2: '#ffa940', name1: 'pages.index.memory', name2: 'pages.index.swap' },
{ key: 'netUp', tab: 'Bandwidth', tabKey: 'pages.index.historyTabBandwidth', title: 'pages.index.historyTitleNetwork', icon: <GlobalOutlined />, valueMax: null, unit: 'B/s', stroke: '#1890ff', key2: 'netDown', stroke2: '#13c2c2', name1: 'Up', name2: 'Down' },
{ key: 'pktUp', tab: 'Packets', tabKey: 'pages.index.historyTabPackets', title: 'pages.index.historyTitlePackets', icon: <DeploymentUnitOutlined />, valueMax: null, unit: 'pkt/s', stroke: '#2f54eb', key2: 'pktDown', stroke2: '#36cfc9', name1: 'Up', name2: 'Down' },
{ key: 'tcpCount', tab: 'Connections', tabKey: 'pages.index.historyTabConnections', title: 'pages.index.historyTitleConnections', icon: <ApiOutlined />, valueMax: null, unit: '', stroke: '#597ef7', key2: 'udpCount', stroke2: '#73d13d', name1: 'TCP', name2: 'UDP' },
{ key: 'diskRead', tab: 'Disk I/O', tabKey: 'pages.index.historyTabDisk', title: 'pages.index.historyTitleDisk', icon: <HddOutlined />, valueMax: null, unit: 'B/s', stroke: '#eb2f96', key2: 'diskWrite', stroke2: '#722ed1', name1: 'Read', name2: 'Write' },
{ key: 'diskUsage', tab: 'Disk Usage', tabKey: 'pages.index.historyTabDiskUsage', title: 'pages.index.historyTitleDiskUsage', icon: <PieChartOutlined />, valueMax: 100, unit: '%', stroke: '#13c2c2' },
{ key: 'online', tab: 'Online', tabKey: 'pages.index.historyTabOnline', title: 'pages.index.historyTitleOnline', icon: <TeamOutlined />, valueMax: null, unit: '', stroke: '#52c41a' },
{ key: 'load1', tab: 'Load', tabKey: 'pages.index.historyTabLoad', title: 'pages.index.historyTitleLoad', icon: <LineChartOutlined />, valueMax: null, unit: '', stroke: '#fa8c16', key2: 'load5', stroke2: '#f5222d', name1: '1m', name2: '5m', key3: 'load15', stroke3: '#a0d911', name3: '15m' },
{
key: 'cpu',
tab: 'CPU',
tabKey: 'pages.index.cpu',
title: 'pages.index.historyTitleCpu',
icon: <DashboardOutlined />,
valueMax: 100,
unit: '%',
stroke: '',
},
{
key: 'mem',
tab: 'RAM',
tabKey: 'pages.index.memory',
title: 'pages.index.historyTitleMem',
icon: <DatabaseOutlined />,
valueMax: 100,
unit: '%',
stroke: '#7c4dff',
key2: 'swap',
stroke2: '#ffa940',
name1: 'pages.index.memory',
name2: 'pages.index.swap',
},
{
key: 'netUp',
tab: 'Bandwidth',
tabKey: 'pages.index.historyTabBandwidth',
title: 'pages.index.historyTitleNetwork',
icon: <GlobalOutlined />,
valueMax: null,
unit: 'B/s',
stroke: '#1890ff',
key2: 'netDown',
stroke2: '#13c2c2',
name1: 'Up',
name2: 'Down',
},
{
key: 'pktUp',
tab: 'Packets',
tabKey: 'pages.index.historyTabPackets',
title: 'pages.index.historyTitlePackets',
icon: <DeploymentUnitOutlined />,
valueMax: null,
unit: 'pkt/s',
stroke: '#2f54eb',
key2: 'pktDown',
stroke2: '#36cfc9',
name1: 'Up',
name2: 'Down',
},
{
key: 'tcpCount',
tab: 'Connections',
tabKey: 'pages.index.historyTabConnections',
title: 'pages.index.historyTitleConnections',
icon: <ApiOutlined />,
valueMax: null,
unit: '',
stroke: '#597ef7',
key2: 'udpCount',
stroke2: '#73d13d',
name1: 'TCP',
name2: 'UDP',
},
{
key: 'diskRead',
tab: 'Disk I/O',
tabKey: 'pages.index.historyTabDisk',
title: 'pages.index.historyTitleDisk',
icon: <HddOutlined />,
valueMax: null,
unit: 'B/s',
stroke: '#eb2f96',
key2: 'diskWrite',
stroke2: '#722ed1',
name1: 'Read',
name2: 'Write',
},
{
key: 'diskUsage',
tab: 'Disk Usage',
tabKey: 'pages.index.historyTabDiskUsage',
title: 'pages.index.historyTitleDiskUsage',
icon: <PieChartOutlined />,
valueMax: 100,
unit: '%',
stroke: '#13c2c2',
},
{
key: 'online',
tab: 'Online',
tabKey: 'pages.index.historyTabOnline',
title: 'pages.index.historyTitleOnline',
icon: <TeamOutlined />,
valueMax: null,
unit: '',
stroke: '#52c41a',
},
{
key: 'load1',
tab: 'Load',
tabKey: 'pages.index.historyTabLoad',
title: 'pages.index.historyTitleLoad',
icon: <LineChartOutlined />,
valueMax: null,
unit: '',
stroke: '#fa8c16',
key2: 'load5',
stroke2: '#f5222d',
name1: '1m',
name2: '5m',
key3: 'load15',
stroke3: '#a0d911',
name3: '15m',
},
];
function unitFormatter(unit: string, activeKey: string): (v: number) => string {
@@ -78,9 +186,10 @@ function unitFormatter(unit: string, activeKey: string): (v: number) => string {
function formatFullTimestamp(unixSec: number): string {
const d = new Date(unixSec * 1000);
const today = new Date();
const sameDay = d.getFullYear() === today.getFullYear()
&& d.getMonth() === today.getMonth()
&& d.getDate() === today.getDate();
const sameDay =
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
@@ -142,7 +251,12 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const lab = bucket >= 2880 ? `${MM}-${DD} ${hh}:${mm}` : bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`;
const lab =
bucket >= 2880
? `${MM}-${DD} ${hh}:${mm}`
: bucket >= 60
? `${hh}:${mm}`
: `${hh}:${mm}:${ss}`;
labs.push(lab);
vals.push(Number(p.v) || 0);
tss.push(Number(p.t) || 0);
@@ -232,7 +346,13 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
return {
key: m.key,
label: isMobile ? <span title={tabLabel} aria-label={tabLabel}>{m.icon}</span> : tabLabel,
label: isMobile ? (
<span title={tabLabel} aria-label={tabLabel}>
{m.icon}
</span>
) : (
tabLabel
),
};
})}
/>
+3 -1
View File
@@ -32,7 +32,9 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
<div className="ov-strip-split">
<div>
<div className="ov-strip-sub">Xray</div>
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.appStats.uptime)}</div>
<div className="ov-strip-value">
{TimeFormatter.formatSecond(status.appStats.uptime)}
</div>
</div>
<span className="ov-strip-split-sep" />
<div>
+7 -1
View File
@@ -16,7 +16,13 @@ interface ThroughputCardProps {
isMobile: boolean;
}
export default function ThroughputCard({ status, up, down, labels, isMobile }: ThroughputCardProps) {
export default function ThroughputCard({
status,
up,
down,
labels,
isMobile,
}: ThroughputCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
+2 -11
View File
@@ -96,12 +96,7 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
const activeKeyStr = Array.isArray(activeKey) ? activeKey[0] : activeKey;
return (
<Modal
open={open}
title={t('pages.index.xrayUpdates')}
footer={null}
onCancel={onClose}
>
<Modal open={open} title={t('pages.index.xrayUpdates')} footer={null} onCancel={onClose}>
{modalContextHolder}
<Spin spinning={loading}>
<Collapse
@@ -168,11 +163,7 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
key: '3',
label: t('pages.index.geodataTitle'),
children: (
<GeodataSection
active={activeKeyStr === '3'}
onBusy={onBusy}
onClose={onClose}
/>
<GeodataSection active={activeKeyStr === '3'} onBusy={onBusy} onClose={onClose} />
),
},
]}
+1 -1
View File
@@ -116,7 +116,7 @@ body.dark .log-container {
--log-divider: rgba(255, 255, 255, 0.1);
}
html[data-theme="ultra-dark"] .log-container {
html[data-theme='ultra-dark'] .log-container {
--log-blocked: #ff8a8a;
--log-proxy: #7fb6f1;
--log-divider: rgba(255, 255, 255, 0.12);
+28 -13
View File
@@ -122,17 +122,19 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
FileManager.downloadTextFile('', 'x-ui.log');
return;
}
const lines = logs.map((l) => {
try {
const dt = l.DateTime ? new Date(l.DateTime) : null;
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
const eventText = eventToken(l.Event);
const emailPart = l.Email ? ` Email=${l.Email}` : '';
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
} catch {
return JSON.stringify(l);
}
}).join('\n');
const lines = logs
.map((l) => {
try {
const dt = l.DateTime ? new Date(l.DateTime) : null;
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
const eventText = eventToken(l.Event);
const emailPart = l.Email ? ` Email=${l.Email}` : '';
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
} catch {
return JSON.stringify(l);
}
})
.join('\n');
FileManager.downloadTextFile(lines, 'x-ui.log');
}
@@ -147,7 +149,15 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
title={
<>
{t('pages.index.accessLogs')}
<SyncOutlined spin={loading} className="reload-icon" role="button" tabIndex={0} aria-label={t('refresh')} onClick={refresh} onKeyDown={activateOnKey(refresh)} />
<SyncOutlined
spin={loading}
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
}
>
@@ -192,7 +202,12 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button type="primary" onClick={download} icon={<DownloadOutlined />} aria-label={t('download')} />
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</Form.Item>
</Form>
+10 -4
View File
@@ -55,11 +55,17 @@
}
@keyframes obs-dot-pulse {
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ant-color-success) 6%, transparent); }
0%,
100% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--ant-color-success) 6%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.obs-dot.is-alive { animation: none; }
.obs-dot.is-alive {
animation: none;
}
}
+103 -35
View File
@@ -48,12 +48,60 @@ interface ObservatoryTag {
}
const METRICS: MetricDef[] = [
{ key: 'xrAlloc', tab: 'Heap', tabKey: 'pages.index.xrayTabHeap', title: 'pages.index.xrayTitleHeap', icon: <DatabaseOutlined />, unit: 'B', stroke: '#7c4dff' },
{ key: 'xrSys', tab: 'Sys', tabKey: 'pages.index.xrayTabSys', title: 'pages.index.xrayTitleSys', icon: <CloudServerOutlined />, unit: 'B', stroke: '#1890ff' },
{ key: 'xrHeapObjects', tab: 'Objects', tabKey: 'pages.index.xrayTabObjects', title: 'pages.index.xrayTitleObjects', icon: <BlockOutlined />, unit: '', stroke: '#13c2c2' },
{ key: 'xrNumGC', tab: 'GC Count', tabKey: 'pages.index.xrayTabGcCount', title: 'pages.index.xrayTitleGcCount', icon: <DeleteOutlined />, unit: '', stroke: '#fa8c16' },
{ key: 'xrPauseNs', tab: 'GC Pause', tabKey: 'pages.index.xrayTabGcPause', title: 'pages.index.xrayTitleGcPause', icon: <PauseCircleOutlined />, unit: 'ns', stroke: '#f5222d' },
{ key: OBS_KEY, tab: 'Observatory', tabKey: 'pages.index.xrayTabObservatory', title: 'pages.index.xrayTitleObservatory', icon: <EyeOutlined />, unit: 'ms', stroke: '#52c41a' },
{
key: 'xrAlloc',
tab: 'Heap',
tabKey: 'pages.index.xrayTabHeap',
title: 'pages.index.xrayTitleHeap',
icon: <DatabaseOutlined />,
unit: 'B',
stroke: '#7c4dff',
},
{
key: 'xrSys',
tab: 'Sys',
tabKey: 'pages.index.xrayTabSys',
title: 'pages.index.xrayTitleSys',
icon: <CloudServerOutlined />,
unit: 'B',
stroke: '#1890ff',
},
{
key: 'xrHeapObjects',
tab: 'Objects',
tabKey: 'pages.index.xrayTabObjects',
title: 'pages.index.xrayTitleObjects',
icon: <BlockOutlined />,
unit: '',
stroke: '#13c2c2',
},
{
key: 'xrNumGC',
tab: 'GC Count',
tabKey: 'pages.index.xrayTabGcCount',
title: 'pages.index.xrayTitleGcCount',
icon: <DeleteOutlined />,
unit: '',
stroke: '#fa8c16',
},
{
key: 'xrPauseNs',
tab: 'GC Pause',
tabKey: 'pages.index.xrayTabGcPause',
title: 'pages.index.xrayTitleGcPause',
icon: <PauseCircleOutlined />,
unit: 'ns',
stroke: '#f5222d',
},
{
key: OBS_KEY,
tab: 'Observatory',
tabKey: 'pages.index.xrayTabObservatory',
title: 'pages.index.xrayTitleObservatory',
icon: <EyeOutlined />,
unit: 'ms',
stroke: '#52c41a',
},
];
function unitFormatter(unit: string): (v: number) => string {
@@ -85,9 +133,10 @@ function fmtTimestamp(unixSec: number): string {
function formatFullTimestamp(unixSec: number): string {
const d = new Date(unixSec * 1000);
const today = new Date();
const sameDay = d.getFullYear() === today.getFullYear()
&& d.getMonth() === today.getMonth()
&& d.getDate() === today.getDate();
const sameDay =
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
@@ -135,29 +184,32 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
[tsLookup],
);
const applyHistory = useCallback((msg: Msg<{ t: number; v: number }[]> | null | undefined, currentBucket: number) => {
if (msg?.success && Array.isArray(msg.obj)) {
const vals: number[] = [];
const labs: string[] = [];
const tss: number[] = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labs.push(currentBucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
vals.push(Number(p.v) || 0);
tss.push(Number(p.t) || 0);
const applyHistory = useCallback(
(msg: Msg<{ t: number; v: number }[]> | null | undefined, currentBucket: number) => {
if (msg?.success && Array.isArray(msg.obj)) {
const vals: number[] = [];
const labs: string[] = [];
const tss: number[] = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labs.push(currentBucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
vals.push(Number(p.v) || 0);
tss.push(Number(p.t) || 0);
}
setLabels(labs);
setPoints(vals);
setTimestamps(tss);
} else {
setLabels([]);
setPoints([]);
setTimestamps([]);
}
setLabels(labs);
setPoints(vals);
setTimestamps(tss);
} else {
setLabels([]);
setPoints([]);
setTimestamps([]);
}
}, []);
},
[],
);
const fetchState = useCallback(async () => {
try {
@@ -255,7 +307,15 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
return () => {
stopObsPolling();
};
}, [open, activeKey, isObservatory, fetchObservatory, fetchObsBucket, fetchMetricBucket, stopObsPolling]);
}, [
open,
activeKey,
isObservatory,
fetchObservatory,
fetchObsBucket,
fetchMetricBucket,
stopObsPolling,
]);
useEffect(() => {
if (!open) return;
@@ -314,7 +374,13 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
return {
key: m.key,
label: isMobile ? <span title={tabLabel} aria-label={tabLabel}>{m.icon}</span> : tabLabel,
label: isMobile ? (
<span title={tabLabel} aria-label={tabLabel}>
{m.icon}
</span>
) : (
tabLabel
),
};
})}
/>
@@ -357,10 +423,12 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
</Tag>
<Tag color="blue">{activeObsTag.delay} ms</Tag>
<span className="obs-stamp">
{t('pages.index.xrayObservatoryLastSeen')}: {fmtTimestamp(activeObsTag.lastSeenTime)}
{t('pages.index.xrayObservatoryLastSeen')}:{' '}
{fmtTimestamp(activeObsTag.lastSeenTime)}
</span>
<span className="obs-stamp">
{t('pages.index.xrayObservatoryLastTry')}: {fmtTimestamp(activeObsTag.lastTryTime)}
{t('pages.index.xrayObservatoryLastTry')}:{' '}
{fmtTimestamp(activeObsTag.lastTryTime)}
</span>
</div>
)}
+14 -2
View File
@@ -6,7 +6,16 @@ import type { Status } from '@/models/status';
const OVERVIEW_WINDOW = 72;
const SEED_BUCKET_SECONDS = 2;
const SERIES_KEYS = ['cpu', 'mem', 'swap', 'diskUsage', 'netUp', 'netDown', 'tcpCount', 'udpCount'] as const;
const SERIES_KEYS = [
'cpu',
'mem',
'swap',
'diskUsage',
'netUp',
'netDown',
'tcpCount',
'udpCount',
] as const;
export type OverviewSeriesKey = (typeof SERIES_KEYS)[number];
@@ -26,7 +35,10 @@ interface HistoryWindow {
}
function emptySeries(): Record<OverviewSeriesKey, number[]> {
return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<OverviewSeriesKey, number[]>;
return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<
OverviewSeriesKey,
number[]
>;
}
function emptyWindow(): HistoryWindow {
+67 -28
View File
@@ -8,10 +8,10 @@
--color-border: rgba(255, 255, 255, 0.6);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 18px 50px rgba(99, 102, 241, 0.18);
--blob-1: rgba(99, 102, 241, 0.55);
--blob-2: rgba(236, 72, 153, 0.50);
--blob-3: rgba(20, 184, 166, 0.40);
--blob-2: rgba(236, 72, 153, 0.5);
--blob-3: rgba(20, 184, 166, 0.4);
--blob-4: rgba(251, 191, 36, 0.35);
--blob-5: rgba(56, 189, 248, 0.40);
--blob-5: rgba(56, 189, 248, 0.4);
--grid-color: rgba(99, 102, 241, 0.06);
--vignette: radial-gradient(ellipse at center, transparent 30%, rgba(0, 0, 0, 0.05) 100%);
@@ -28,13 +28,13 @@
--color-text: rgba(255, 255, 255, 0.92);
--color-text-subtle: rgba(255, 255, 255, 0.55);
--color-accent: #3b82f6;
--color-border: rgba(255, 255, 255, 0.10);
--color-border: rgba(255, 255, 255, 0.1);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 20px 60px rgba(59, 130, 246, 0.22);
--blob-1: rgba(96, 165, 250, 0.42);
--blob-2: rgba(59, 130, 246, 0.42);
--blob-3: rgba(37, 99, 235, 0.35);
--blob-4: rgba(251, 146, 60, 0.18);
--blob-5: rgba(129, 140, 248, 0.30);
--blob-5: rgba(129, 140, 248, 0.3);
--grid-color: rgba(255, 255, 255, 0.04);
--vignette: radial-gradient(ellipse at center, transparent 25%, rgba(0, 0, 0, 0.5) 100%);
background: radial-gradient(ellipse at 25% 20%, #152038 0%, #0d1117 60%);
@@ -45,9 +45,9 @@
--bg-card: rgba(15, 17, 28, 0.6);
--bg-card-solid: #101013;
--color-border: rgba(255, 255, 255, 0.06);
--blob-1: rgba(99, 102, 241, 0.30);
--blob-1: rgba(99, 102, 241, 0.3);
--blob-2: rgba(236, 72, 153, 0.22);
--blob-3: rgba(34, 211, 238, 0.20);
--blob-3: rgba(34, 211, 238, 0.2);
--blob-4: rgba(251, 146, 60, 0.15);
--blob-5: rgba(168, 85, 247, 0.25);
--grid-color: rgba(255, 255, 255, 0.025);
@@ -154,33 +154,63 @@
}
@keyframes blob-drift-a {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18vw, 10vh) scale(1.15); }
100% { transform: translate(34vw, 22vh) scale(1.25); }
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(18vw, 10vh) scale(1.15);
}
100% {
transform: translate(34vw, 22vh) scale(1.25);
}
}
@keyframes blob-drift-b {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-16vw, -10vh) scale(1.12); }
100% { transform: translate(-30vw, -22vh) scale(1.2); }
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-16vw, -10vh) scale(1.12);
}
100% {
transform: translate(-30vw, -22vh) scale(1.2);
}
}
@keyframes blob-drift-c {
0% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-20%, -20%) scale(1.1); }
100% { transform: translate(-80%, -10%) scale(1.05); }
0% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-20%, -20%) scale(1.1);
}
100% {
transform: translate(-80%, -10%) scale(1.05);
}
}
@keyframes blob-drift-d {
0% { transform: translate(0, 0) scale(0.9); }
50% { transform: translate(-12vw, 14vh) scale(1.05); }
100% { transform: translate(8vw, -8vh) scale(1.1); }
0% {
transform: translate(0, 0) scale(0.9);
}
50% {
transform: translate(-12vw, 14vh) scale(1.05);
}
100% {
transform: translate(8vw, -8vh) scale(1.1);
}
}
@keyframes blob-drift-e {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(14vw, -8vh) scale(1.1); }
100% { transform: translate(-6vw, 12vh) scale(1.15); }
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(14vw, -8vh) scale(1.1);
}
100% {
transform: translate(-6vw, 12vh) scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
@@ -267,7 +297,9 @@
rgba(255, 255, 255, 0) 40%,
rgba(99, 102, 241, 0.25) 80%
);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
@@ -278,7 +310,7 @@
135deg,
rgba(255, 255, 255, 0.15),
rgba(255, 255, 255, 0) 40%,
rgba(59, 130, 246, 0.40) 80%
rgba(59, 130, 246, 0.4) 80%
);
}
@@ -344,8 +376,14 @@
}
@keyframes headline-in {
0% { opacity: 0; transform: translateY(6px); }
100% { opacity: 1; transform: translateY(0); }
0% {
opacity: 0;
transform: translateY(6px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
@@ -365,10 +403,11 @@
-webkit-text-fill-color: var(--color-text) !important;
-webkit-box-shadow: 0 0 0 1000px var(--bg-card-solid) inset !important;
box-shadow: 0 0 0 1000px var(--bg-card-solid) inset !important;
transition: background-color 9999s ease-in-out 0s, color 9999s ease-in-out 0s;
transition:
background-color 9999s ease-in-out 0s,
color 9999s ease-in-out 0s;
}
.submit-row {
margin-bottom: 0;
}
+18 -14
View File
@@ -49,13 +49,12 @@ export default function LoginPage() {
const [submitting, setSubmitting] = useState(false);
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
const [headlineIndex, setHeadlineIndex] = useState(0);
const methods = useForm<LoginForm>({ defaultValues: { username: '', password: '', twoFactorCode: '' } });
const methods = useForm<LoginForm>({
defaultValues: { username: '', password: '', twoFactorCode: '' },
});
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const headlineWords = useMemo(
() => [t('pages.login.hello'), t('pages.login.title')],
[t],
);
const headlineWords = useMemo(() => [t('pages.login.hello'), t('pages.login.title')], [t]);
useEffect(() => {
const timer = window.setInterval(() => {
@@ -72,7 +71,9 @@ export default function LoginPage() {
if (msg.success) setTwoFactorEnable(!!msg.obj);
setFetched(true);
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
const onSubmit = useCallback(async (values: LoginForm) => {
@@ -111,15 +112,18 @@ export default function LoginPage() {
}, [isDark, isUltra]);
const langMenuItems = useMemo(
() => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
key: l.value,
label: (
<Space size={8}>
<span aria-hidden="true">{l.icon}</span>
<span>{l.name}</span>
</Space>
() =>
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
(l) => ({
key: l.value,
label: (
<Space size={8}>
<span aria-hidden="true">{l.icon}</span>
<span>{l.name}</span>
</Space>
),
}),
),
})),
[],
);
+39 -23
View File
@@ -87,31 +87,38 @@ export default function NodeFormModal({
// connection") rather than a synthetic option, so it can't read as a second
// "direct" next to a real freedom outbound.
const outboundOptions = useMemo<
({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
(
| { label: string; value: string }
| { label: string; options: { label: string; value: string }[] }
)[]
>(() => {
const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
if (!outboundGroups?.balancers.length) return outOpts;
return [
{ label: t('pages.xray.Outbounds'), options: outOpts },
{ label: t('pages.xray.Balancers'), options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })) },
{
label: t('pages.xray.Balancers'),
options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })),
},
];
}, [outboundGroups, t]);
useEffect(() => {
if (!open) return;
const base = defaultValues();
const next: NodeFormValues = mode === 'edit' && node
? {
...base,
...(node as unknown as Partial<NodeFormValues>),
id: node.id,
scheme: (node.scheme as 'http' | 'https') || base.scheme,
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
inboundTags: node.inboundTags ?? [],
apiToken: '',
hasStoredToken: node.hasApiToken ?? false,
}
: base;
const next: NodeFormValues =
mode === 'edit' && node
? {
...base,
...(node as unknown as Partial<NodeFormValues>),
id: node.id,
scheme: (node.scheme as 'http' | 'https') || base.scheme,
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
inboundTags: node.inboundTags ?? [],
apiToken: '',
hasStoredToken: node.hasApiToken ?? false,
}
: base;
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
methods.reset(next);
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
@@ -209,7 +216,9 @@ export default function NodeFormModal({
const test = await testConnection(payload);
const probe = test?.success ? test.obj : null;
if (!probe || probe.status !== 'online') {
setTestResult(probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') });
setTestResult(
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
);
return;
}
setTestResult(probe);
@@ -303,11 +312,7 @@ export default function NodeFormModal({
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.enable')}
name="enable"
valueProp="checked"
>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch />
</FormField>
</Col>
@@ -379,7 +384,11 @@ export default function NodeFormModal({
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
>
<Input.Password
placeholder={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : t('pages.nodes.apiTokenPlaceholder')}
placeholder={
editingWithToken
? t('pages.nodes.apiTokenKeepHint')
: t('pages.nodes.apiTokenPlaceholder')
}
/>
</FormField>
@@ -423,7 +432,12 @@ export default function NodeFormModal({
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
popupRender={(menu) => (
<>
<Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
<Button
type="text"
block
loading={fetchingInbounds}
onClick={onFetchInbounds}
>
{t('pages.nodes.loadInbounds')}
</Button>
{menu}
@@ -448,7 +462,9 @@ export default function NodeFormModal({
type="success"
showIcon
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
description={
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
}
/>
) : (
<Alert
@@ -55,7 +55,7 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
try {
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
const msg = await HttpUtil.get(url) as ApiMsg<SeriesPoint[]>;
const msg = (await HttpUtil.get(url)) as ApiMsg<SeriesPoint[]>;
if (msg?.success && Array.isArray(msg.obj)) {
const vals: number[] = [];
const labs: string[] = [];
+480 -310
View File
@@ -1,17 +1,6 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Card,
Dropdown,
Modal,
Space,
Switch,
Table,
Tag,
Tooltip,
} from 'antd';
import { Badge, Button, Card, Dropdown, Modal, Space, Switch, Table, Tag, Tooltip } from 'antd';
import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -66,9 +55,12 @@ interface NodeRow extends NodeRecord {
function badgeStatus(status?: string): BadgeProps['status'] {
switch (status) {
case 'online': return 'success';
case 'offline': return 'error';
default: return 'default';
case 'online':
return 'success';
case 'offline':
return 'error';
default:
return 'default';
}
}
@@ -92,18 +84,24 @@ function hasXrayProblem(status?: string, xrayState?: string): boolean {
// Tooltip text + icon color for the status cell. A real probe error (lastError)
// is a warning and takes precedence; otherwise an Xray-core problem shows purple.
function statusIssue(record: Pick<NodeRecord, 'status' | 'xrayState' | 'xrayError' | 'lastError'>) {
const tip = record.lastError || (hasXrayProblem(record.status, record.xrayState) ? record.xrayError : '') || '';
const iconColor = !record.lastError && hasXrayProblem(record.status, record.xrayState)
? XRAY_ERROR_COLOR
: 'var(--ant-color-warning)';
const tip =
record.lastError ||
(hasXrayProblem(record.status, record.xrayState) ? record.xrayError : '') ||
'';
const iconColor =
!record.lastError && hasXrayProblem(record.status, record.xrayState)
? XRAY_ERROR_COLOR
: 'var(--ant-color-warning)';
return { tip, iconColor };
}
function StatusDot({ status, xrayState }: HealthProps) {
if (status === 'online') {
return hasXrayProblem(status, xrayState)
? <span className="xray-error-dot" />
: <span className="online-dot" />;
return hasXrayProblem(status, xrayState) ? (
<span className="xray-error-dot" />
) : (
<span className="online-dot" />
);
}
return <Badge status={badgeStatus(status)} />;
}
@@ -113,9 +111,10 @@ function StatusLabel({ status, xrayState }: HealthProps) {
if (status === 'online') {
const xs = (xrayState || '').toLowerCase().trim();
if (xs === 'error' || xs === 'stop') {
const detail = xs === 'error'
? t('pages.nodes.statusValues.xrayError')
: t('pages.nodes.statusValues.xrayStopped');
const detail =
xs === 'error'
? t('pages.nodes.statusValues.xrayError')
: t('pages.nodes.statusValues.xrayStopped');
return (
<span style={{ color: XRAY_ERROR_COLOR }}>
{t('pages.nodes.statusValues.online')} ({detail})
@@ -229,206 +228,307 @@ export default function NodeList({
function toggleExpanded(id: number) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const columns = useMemo<ColumnsType<NodeRow>>(() => [
{
title: t('pages.nodes.actions'),
align: 'center',
width: 190,
render: (_value, record) => record.transitive ? (
<Tooltip title={t('pages.nodes.subNodeTip', { parent: record.parentGuid ? (nameByGuid.get(record.parentGuid) || '-') : '-' })}>
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
</Tooltip>
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} aria-label={t('pages.nodes.probe')} onClick={() => onProbe(record)} />
</Tooltip>
{isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} aria-label={t('pages.nodes.updatePanel')} onClick={() => onUpdateNode(record)} />
const columns = useMemo<ColumnsType<NodeRow>>(
() => [
{
title: t('pages.nodes.actions'),
align: 'center',
width: 190,
render: (_value, record) =>
record.transitive ? (
<Tooltip
title={t('pages.nodes.subNodeTip', {
parent: record.parentGuid ? nameByGuid.get(record.parentGuid) || '-' : '-',
})}
>
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
{t('pages.nodes.subNode')}
</Tag>
</Tooltip>
)}
<Tooltip title={t('edit')}>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
},
{
title: t('pages.nodes.enable'),
dataIndex: 'enable',
align: 'center',
width: 80,
render: (_value, record) => record.transitive ? (
<span style={{ opacity: 0.4 }}></span>
) : (
<Switch
checked={!!record.enable}
size="small"
onChange={(v) => onToggleEnable(record, v)}
/>
),
},
{
title: t('pages.nodes.name'),
dataIndex: 'name',
ellipsis: true,
render: (_value, record) => (
<div className="name-cell" style={record.transitive ? { paddingInlineStart: 20 } : undefined}>
<span className="name">
{record.transitive && <ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />}
{record.name}
</span>
{record.remark && <span className="remark">{record.remark}</span>}
</div>
),
},
{
title: (
<span className="address-header">
{t('pages.nodes.address')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? (
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(false)} onKeyDown={activateOnKey(() => setShowAddress(false))} />
) : (
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(true)} onKeyDown={activateOnKey(() => setShowAddress(true))} />
)}
</Tooltip>
</span>
),
dataIndex: 'url',
ellipsis: true,
render: (_value, record) => (
<a
href={record.url}
target="_blank"
rel="noopener noreferrer"
className={showAddress ? 'address-visible' : 'address-hidden'}
>
{record.url}
</a>
),
},
{
title: t('pages.nodes.status'),
dataIndex: 'status',
align: 'center',
render: (_value, record) => {
const { tip, iconColor } = statusIssue(record);
return (
<Space size={4}>
<StatusDot status={record.status} xrayState={record.xrayState} />
<StatusLabel status={record.status} xrayState={record.xrayState} />
{tip && (
<Tooltip title={tip}>
<ExclamationCircleOutlined style={{ color: iconColor }} />
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<ThunderboltOutlined />}
aria-label={t('pages.nodes.probe')}
onClick={() => onProbe(record)}
/>
</Tooltip>
)}
</Space>
);
{isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<CloudDownloadOutlined />}
aria-label={t('pages.nodes.updatePanel')}
onClick={() => onUpdateNode(record)}
/>
</Tooltip>
)}
<Tooltip title={t('edit')}>
<Button
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<EditOutlined />}
aria-label={t('edit')}
onClick={() => onEdit(record)}
/>
</Tooltip>
<Tooltip title={t('delete')}>
<Button
type="text"
size="small"
danger
style={{ fontSize: 16 }}
icon={<DeleteOutlined />}
aria-label={t('delete')}
onClick={() => onDelete(record)}
/>
</Tooltip>
</Space>
),
},
},
{
title: t('pages.nodes.cpu'),
dataIndex: 'cpuPct',
align: 'center',
width: 90,
render: (_value, record) => formatPct(record.cpuPct),
},
{
title: t('pages.nodes.mem'),
dataIndex: 'memPct',
align: 'center',
width: 90,
render: (_value, record) => formatPct(record.memPct),
},
{
title: t('pages.nodes.xrayVersion'),
dataIndex: 'xrayVersion',
align: 'center',
render: (_value, record) => record.xrayVersion || '-',
},
{
title: t('pages.nodes.panelVersion') || 'Panel version',
dataIndex: 'panelVersion',
align: 'center',
render: (_value, record) => {
const canUpdate = isUpdateEligible(record)
&& isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
return (
<Space size={4}>
<span>{record.panelVersion || '-'}</span>
{canUpdate && (
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
<Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} role="button" tabIndex={0} onClick={() => onUpdateNode(record)} onKeyDown={activateOnKey(() => onUpdateNode(record))}>
{t('pages.nodes.updateAvailable')}
{
title: t('pages.nodes.enable'),
dataIndex: 'enable',
align: 'center',
width: 80,
render: (_value, record) =>
record.transitive ? (
<span style={{ opacity: 0.4 }}></span>
) : (
<Switch
checked={!!record.enable}
size="small"
onChange={(v) => onToggleEnable(record, v)}
/>
),
},
{
title: t('pages.nodes.name'),
dataIndex: 'name',
ellipsis: true,
render: (_value, record) => (
<div
className="name-cell"
style={record.transitive ? { paddingInlineStart: 20 } : undefined}
>
<span className="name">
{record.transitive && (
<ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />
)}
{record.name}
</span>
{record.remark && <span className="remark">{record.remark}</span>}
</div>
),
},
{
title: (
<span className="address-header">
{t('pages.nodes.address')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowAddress(false)}
onKeyDown={activateOnKey(() => setShowAddress(false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowAddress(true)}
onKeyDown={activateOnKey(() => setShowAddress(true))}
/>
)}
</Tooltip>
</span>
),
dataIndex: 'url',
ellipsis: true,
render: (_value, record) => (
<a
href={record.url}
target="_blank"
rel="noopener noreferrer"
className={showAddress ? 'address-visible' : 'address-hidden'}
>
{record.url}
</a>
),
},
{
title: t('pages.nodes.status'),
dataIndex: 'status',
align: 'center',
render: (_value, record) => {
const { tip, iconColor } = statusIssue(record);
return (
<Space size={4}>
<StatusDot status={record.status} xrayState={record.xrayState} />
<StatusLabel status={record.status} xrayState={record.xrayState} />
{tip && (
<Tooltip title={tip}>
<ExclamationCircleOutlined style={{ color: iconColor }} />
</Tooltip>
)}
</Space>
);
},
},
{
title: t('pages.nodes.cpu'),
dataIndex: 'cpuPct',
align: 'center',
width: 90,
render: (_value, record) => formatPct(record.cpuPct),
},
{
title: t('pages.nodes.mem'),
dataIndex: 'memPct',
align: 'center',
width: 90,
render: (_value, record) => formatPct(record.memPct),
},
{
title: t('pages.nodes.xrayVersion'),
dataIndex: 'xrayVersion',
align: 'center',
render: (_value, record) => record.xrayVersion || '-',
},
{
title: t('pages.nodes.panelVersion') || 'Panel version',
dataIndex: 'panelVersion',
align: 'center',
render: (_value, record) => {
const canUpdate =
isUpdateEligible(record) &&
isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
return (
<Space size={4}>
<span>{record.panelVersion || '-'}</span>
{canUpdate && (
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
<Tag
color="orange"
style={{ margin: 0, cursor: 'pointer' }}
role="button"
tabIndex={0}
onClick={() => onUpdateNode(record)}
onKeyDown={activateOnKey(() => onUpdateNode(record))}
>
{t('pages.nodes.updateAvailable')}
</Tag>
</Tooltip>
)}
</Space>
);
},
},
{
title: t('pages.nodes.uptime'),
dataIndex: 'uptimeSecs',
align: 'center',
render: (_value, record) => formatUptime(record.uptimeSecs),
},
{
title: t('clients'),
align: 'center',
width: 180,
render: (_value, record) => (
<Space size={2}>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
<TeamOutlined /> {record.clientCount || 0}
</Tag>
{record.activeCount ? (
<Tooltip title={t('subscription.active')}>
<Tag
color="green"
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{record.activeCount}
</Tag>
</Tooltip>
)}
) : null}
{record.disabledCount ? (
<Tooltip title={t('disabled')}>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
{record.disabledCount}
</Tag>
</Tooltip>
) : null}
{record.depletedCount ? (
<Tooltip title={t('depleted')}>
<Tag
color="red"
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{record.depletedCount}
</Tag>
</Tooltip>
) : null}
{record.onlineCount ? (
<Tooltip title={t('online')}>
<Tag
color="blue"
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{record.onlineCount}
</Tag>
</Tooltip>
) : null}
</Space>
);
),
},
},
{
title: t('pages.nodes.uptime'),
dataIndex: 'uptimeSecs',
align: 'center',
render: (_value, record) => formatUptime(record.uptimeSecs),
},
{
title: t('clients'),
align: 'center',
width: 180,
render: (_value, record) => (
<Space size={2}>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}><TeamOutlined /> {record.clientCount || 0}</Tag>
{record.activeCount ? (
<Tooltip title={t('subscription.active')}>
<Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{record.activeCount}</Tag>
</Tooltip>
) : null}
{record.disabledCount ? (
<Tooltip title={t('disabled')}>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{record.disabledCount}</Tag>
</Tooltip>
) : null}
{record.depletedCount ? (
<Tooltip title={t('depleted')}>
<Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{record.depletedCount}</Tag>
</Tooltip>
) : null}
{record.onlineCount ? (
<Tooltip title={t('online')}>
<Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{record.onlineCount}</Tag>
</Tooltip>
) : null}
</Space>
),
},
{
title: t('pages.nodes.latency'),
dataIndex: 'latencyMs',
align: 'center',
width: 100,
render: (_value, record) =>
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
},
{
title: t('pages.nodes.lastHeartbeat'),
dataIndex: 'lastHeartbeat',
align: 'center',
width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat),
},
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode, nameByGuid]);
{
title: t('pages.nodes.latency'),
dataIndex: 'latencyMs',
align: 'center',
width: 100,
render: (_value, record) =>
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
},
{
title: t('pages.nodes.lastHeartbeat'),
dataIndex: 'lastHeartbeat',
align: 'center',
width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat),
},
],
[
t,
showAddress,
relativeTime,
latestVersion,
onToggleEnable,
onProbe,
onEdit,
onDelete,
onUpdateNode,
nameByGuid,
],
);
return (
<Card size="small" hoverable>
@@ -455,93 +555,128 @@ export default function NodeList({
<div>{t('noData')}</div>
</div>
) : (
dataSource.map((record) => record.transitive ? (
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
<div className="card-head">
<ApartmentOutlined style={{ opacity: 0.6 }} />
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions">
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
</div>
</div>
</div>
) : (
<div key={record.id} className="node-card">
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
dataSource.map((record) =>
record.transitive ? (
<div
className="card-head"
onClick={(e) => {
if (!(e.target as HTMLElement).closest('.card-actions')) toggleExpanded(record.id);
}}
key={String(record.key)}
className="node-card"
style={{ paddingInlineStart: 16, opacity: 0.85 }}
>
<RightOutlined
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
role="button"
tabIndex={0}
aria-expanded={expandedIds.has(record.id)}
aria-label={record.name}
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
/>
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions">
<Tooltip title={t('info')}>
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('info')}
onClick={() => setStatsNode(record)}
onKeyDown={activateOnKey(() => setStatsNode(record))}
/>
</Tooltip>
<Switch
checked={!!record.enable}
size="small"
onChange={(v) => onToggleEnable(record, v)}
/>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: [
{
key: 'probe',
label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
onClick: () => onProbe(record),
},
...(isUpdateEligible(record) ? [{
key: 'update',
label: <><CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}</>,
onClick: () => onUpdateNode(record),
}] : []),
{
key: 'edit',
label: <><EditOutlined /> {t('edit')}</>,
onClick: () => onEdit(record),
},
{
key: 'delete',
danger: true,
label: <><DeleteOutlined /> {t('delete')}</>,
onClick: () => onDelete(record),
},
],
}}
>
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
<div className="card-head">
<ApartmentOutlined style={{ opacity: 0.6 }} />
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions">
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
{t('pages.nodes.subNode')}
</Tag>
</div>
</div>
</div>
{expandedIds.has(record.id) && (
<div className="card-history">
<NodeHistoryPanel node={record} />
) : (
<div key={record.id} className="node-card">
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
<div
className="card-head"
onClick={(e) => {
if (!(e.target as HTMLElement).closest('.card-actions'))
toggleExpanded(record.id);
}}
>
<RightOutlined
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
role="button"
tabIndex={0}
aria-expanded={expandedIds.has(record.id)}
aria-label={record.name}
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
/>
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions">
<Tooltip title={t('info')}>
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('info')}
onClick={() => setStatsNode(record)}
onKeyDown={activateOnKey(() => setStatsNode(record))}
/>
</Tooltip>
<Switch
checked={!!record.enable}
size="small"
onChange={(v) => onToggleEnable(record, v)}
/>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: [
{
key: 'probe',
label: (
<>
<ThunderboltOutlined /> {t('pages.nodes.probe')}
</>
),
onClick: () => onProbe(record),
},
...(isUpdateEligible(record)
? [
{
key: 'update',
label: (
<>
<CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}
</>
),
onClick: () => onUpdateNode(record),
},
]
: []),
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => onEdit(record),
},
{
key: 'delete',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => onDelete(record),
},
],
}}
>
<Button
type="text"
size="small"
className="row-action-trigger"
icon={<MoreOutlined />}
aria-label={t('more')}
/>
</Dropdown>
</div>
</div>
)}
</div>
))
{expandedIds.has(record.id) && (
<div className="card-history">
<NodeHistoryPanel node={record} />
</div>
)}
</div>
),
)
)}
</div>
@@ -573,9 +708,23 @@ export default function NodeList({
</a>
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? (
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(false)} onKeyDown={activateOnKey(() => setShowAddress(false))} />
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowAddress(false)}
onKeyDown={activateOnKey(() => setShowAddress(false))}
/>
) : (
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(true)} onKeyDown={activateOnKey(() => setShowAddress(true))} />
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowAddress(true)}
onKeyDown={activateOnKey(() => setShowAddress(true))}
/>
)}
</Tooltip>
</div>
@@ -605,7 +754,9 @@ export default function NodeList({
<Tag>{statsNode.xrayVersion || '-'}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.panelVersion') || 'Panel version'}</span>
<span className="stat-label">
{t('pages.nodes.panelVersion') || 'Panel version'}
</span>
<Tag>{statsNode.panelVersion || '-'}</Tag>
</div>
<div className="stat-row">
@@ -615,23 +766,35 @@ export default function NodeList({
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.latency')}</span>
<Tag>
{statsNode.latencyMs && statsNode.latencyMs > 0 ? `${statsNode.latencyMs} ms` : '-'}
{statsNode.latencyMs && statsNode.latencyMs > 0
? `${statsNode.latencyMs} ms`
: '-'}
</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('clients')}</span>
<Tag><TeamOutlined /> {statsNode.clientCount || 0}</Tag>
<Tag>
<TeamOutlined /> {statsNode.clientCount || 0}
</Tag>
{statsNode.activeCount ? (
<Tag color="green">{statsNode.activeCount} {t('subscription.active')}</Tag>
<Tag color="green">
{statsNode.activeCount} {t('subscription.active')}
</Tag>
) : null}
{statsNode.disabledCount ? (
<Tag>{statsNode.disabledCount} {t('disabled')}</Tag>
<Tag>
{statsNode.disabledCount} {t('disabled')}
</Tag>
) : null}
{statsNode.depletedCount ? (
<Tag color="red">{statsNode.depletedCount} {t('depleted')}</Tag>
<Tag color="red">
{statsNode.depletedCount} {t('depleted')}
</Tag>
) : null}
{statsNode.onlineCount ? (
<Tag color="blue">{statsNode.onlineCount} {t('online')}</Tag>
<Tag color="blue">
{statsNode.onlineCount} {t('online')}
</Tag>
) : null}
</div>
<div className="stat-row">
@@ -651,11 +814,18 @@ export default function NodeList({
scroll={{ x: 'max-content' }}
size="middle"
rowKey="key"
rowSelection={dataSource.length > 1 ? {
selectedRowKeys: selectedIds,
onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
getCheckboxProps: (record) => ({ disabled: !!record.transitive || !isUpdateEligible(record) }),
} : undefined}
rowSelection={
dataSource.length > 1
? {
selectedRowKeys: selectedIds,
onChange: (keys) =>
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
getCheckboxProps: (record) => ({
disabled: !!record.transitive || !isUpdateEligible(record),
}),
}
: undefined
}
locale={{
emptyText: (
<div className="card-empty">
+141 -72
View File
@@ -1,7 +1,23 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Alert, Button, Card, Checkbox, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
import {
Alert,
Button,
Card,
Checkbox,
Col,
ConfigProvider,
Input,
Layout,
Modal,
Result,
Row,
Spin,
Statistic,
Typography,
message,
} from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
@@ -32,7 +48,10 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
<p>{t('pages.nodes.updateConfirmContent')}</p>
<Checkbox
checked={dev}
onChange={(e) => { setDev(e.target.checked); onChange(e.target.checked); }}
onChange={(e) => {
setDev(e.target.checked);
onChange(e.target.checked);
}}
>
{t('pages.nodes.updateDevChannel')}
</Checkbox>
@@ -54,10 +73,22 @@ export default function NodesPage() {
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
const { create, update, remove, setEnable, testConnection, fetchFingerprint, fetchInbounds, probe, updatePanels } = useNodeMutations();
const {
create,
update,
remove,
setEnable,
testConnection,
fetchFingerprint,
fetchInbounds,
probe,
updatePanels,
} = useNodeMutations();
const { data: latestVersion = '' } = useQuery({
queryKey: ['server', 'panelUpdateInfo'],
@@ -124,76 +155,100 @@ export default function NodesPage() {
setFormOpen(true);
}, []);
const onSave = useCallback(async (payload: Partial<NodeRecord>) => {
if (formMode === 'edit' && formNode?.id) {
return update(formNode.id, payload);
}
return create(payload);
}, [formMode, formNode, update, create]);
const onDelete = useCallback((node: NodeRecord) => {
modal.confirm({
title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
content: t('pages.nodes.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(node.id);
if (msg?.success) messageApi.success(t('pages.nodes.toasts.deleted'));
},
});
}, [modal, t, remove, messageApi]);
const onProbe = useCallback(async (node: NodeRecord) => {
const msg = await probe(node.id);
if (msg?.success && msg.obj) {
if (msg.obj.status === 'online') {
// Even if xray is in error/stop on the node we still reached its panel API.
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
} else {
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
const onSave = useCallback(
async (payload: Partial<NodeRecord>) => {
if (formMode === 'edit' && formNode?.id) {
return update(formNode.id, payload);
}
}
// Refresh the list so the new xrayState / xrayError (if any) appears immediately in the row.
refetch();
}, [probe, t, messageApi, refetch]);
return create(payload);
},
[formMode, formNode, update, create],
);
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
await setEnable(node.id, next);
}, [setEnable]);
const onDelete = useCallback(
(node: NodeRecord) => {
modal.confirm({
title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
content: t('pages.nodes.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(node.id);
if (msg?.success) messageApi.success(t('pages.nodes.toasts.deleted'));
},
});
},
[modal, t, remove, messageApi],
);
const onProbe = useCallback(
async (node: NodeRecord) => {
const msg = await probe(node.id);
if (msg?.success && msg.obj) {
if (msg.obj.status === 'online') {
// Even if xray is in error/stop on the node we still reached its panel API.
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
} else {
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
}
}
// Refresh the list so the new xrayState / xrayError (if any) appears immediately in the row.
refetch();
},
[probe, t, messageApi, refetch],
);
const onToggleEnable = useCallback(
async (node: NodeRecord, next: boolean) => {
await setEnable(node.id, next);
},
[setEnable],
);
const devRef = useRef(false);
const runUpdate = useCallback(async (ids: number[], dev: boolean) => {
const msg = await updatePanels(ids, dev);
if (!msg?.success) {
messageApi.error(msg?.msg || t('somethingWentWrong'));
return;
}
const results = msg.obj ?? [];
const ok = results.filter((r) => r.ok).length;
const failed = results.length - ok;
if (failed === 0) {
messageApi.success(t('pages.nodes.toasts.updateStarted'));
} else {
const firstError = results.find((r) => !r.ok)?.error ?? '';
const base = t('pages.nodes.toasts.updateResult', { ok, failed });
messageApi.warning(firstError ? `${base}${firstError}` : base);
}
setSelectedIds([]);
}, [updatePanels, messageApi, t]);
const runUpdate = useCallback(
async (ids: number[], dev: boolean) => {
const msg = await updatePanels(ids, dev);
if (!msg?.success) {
messageApi.error(msg?.msg || t('somethingWentWrong'));
return;
}
const results = msg.obj ?? [];
const ok = results.filter((r) => r.ok).length;
const failed = results.length - ok;
if (failed === 0) {
messageApi.success(t('pages.nodes.toasts.updateStarted'));
} else {
const firstError = results.find((r) => !r.ok)?.error ?? '';
const base = t('pages.nodes.toasts.updateResult', { ok, failed });
messageApi.warning(firstError ? `${base}${firstError}` : base);
}
setSelectedIds([]);
},
[updatePanels, messageApi, t],
);
const onUpdateNode = useCallback((node: NodeRecord) => {
devRef.current = false;
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate([node.id], devRef.current),
});
}, [modal, t, runUpdate]);
const onUpdateNode = useCallback(
(node: NodeRecord) => {
devRef.current = false;
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
content: (
<UpdateChannelChoice
onChange={(v) => {
devRef.current = v;
}}
/>
),
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate([node.id], devRef.current),
});
},
[modal, t, runUpdate],
);
const onUpdateSelected = useCallback(() => {
const eligible = nodes
@@ -206,7 +261,13 @@ export default function NodesPage() {
devRef.current = false;
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
content: (
<UpdateChannelChoice
onChange={(v) => {
devRef.current = v;
}}
/>
),
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate(eligible, devRef.current),
@@ -237,7 +298,11 @@ export default function NodesPage() {
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={<Button type="primary" loading={loading} onClick={() => refetch()}>{t('refresh')}</Button>}
extra={
<Button type="primary" loading={loading} onClick={() => refetch()}>
{t('refresh')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
@@ -255,14 +320,18 @@ export default function NodesPage() {
<Statistic
title={t('pages.nodes.onlineNodes')}
value={String(totals.online)}
prefix={<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />}
prefix={
<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />
}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.nodes.offlineNodes')}
value={String(totals.offline)}
prefix={<CloseCircleOutlined style={{ color: 'var(--ant-color-error)' }} />}
prefix={
<CloseCircleOutlined style={{ color: 'var(--ant-color-error)' }} />
}
/>
</Col>
<Col xs={12} sm={12} md={6}>
+181 -96
View File
@@ -38,110 +38,195 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
setTestLoading(true);
setTestResult(null);
try {
const res = await HttpUtil.post('/panel/api/setting/testSmtp') as SmtpTestResult;
const res = (await HttpUtil.post('/panel/api/setting/testSmtp')) as SmtpTestResult;
setTestResult(res);
} catch (e: unknown) {
setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
setTestResult({
success: false,
msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'),
});
} finally {
setTestLoading(false);
}
}
return (
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.smtpEnable')} description={t('pages.settings.smtpEnableDesc')}>
<Switch checked={allSetting.smtpEnable} onChange={(v) => updateSetting({ smtpEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpHost')} description={t('pages.settings.smtpHostDesc')}>
<Input value={allSetting.smtpHost} placeholder="smtp.gmail.com"
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />} description={t('pages.settings.smtpPortDesc')}>
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
<Input value={allSetting.smtpUsername} placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpUsername: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
description={allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
<SecretInput value={allSetting.smtpPassword}
configured={allSetting.hasSmtpPassword}
clearArmed={allSetting.clearSmtpPassword}
placeholder={t('pages.settings.smtpPasswordPlaceholder')}
onChange={(v) => updateSetting({ smtpPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpFrom')} description={t('pages.settings.smtpFromDesc')}>
<Input value={allSetting.smtpFrom} placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpFrom: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpFromName')} description={t('pages.settings.smtpFromNameDesc')}>
<Input value={allSetting.smtpFromName} placeholder="3x-ui"
onChange={(e) => updateSetting({ smtpFromName: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
<Input value={allSetting.smtpTo} placeholder="admin@example.com, ops@example.com"
onChange={(e) => updateSetting({ smtpTo: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpEncryption')} description={t('pages.settings.smtpEncryptionDesc')}>
<Select
value={allSetting.smtpEncryptionType}
onChange={(v) => updateSetting({ smtpEncryptionType: v })}
options={[
{ value: 'none', label: t('pages.settings.smtpEncryptionNone') },
{ value: 'starttls', label: t('pages.settings.smtpEncryptionStartTLS') },
{ value: 'tls', label: t('pages.settings.smtpEncryptionTLS') },
]}
style={{ width: '100%' }}
/>
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestSmtp}>
{t('pages.settings.testSmtp')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
title={
testResult.success
? t('pages.settings.' + testResult.msg)
: <span><b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b> {t('pages.settings.' + testResult.msg)}</span>
}
showIcon
closable={{ onClose: () => setTestResult(null) }}
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEnable')}
description={t('pages.settings.smtpEnableDesc')}
>
<Switch
checked={allSetting.smtpEnable}
onChange={(v) => updateSetting({ smtpEnable: v })}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.smtpEventBusNotify')} description={t('pages.settings.smtpEventBusNotifyDesc')}>
<EmailNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
},
]} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpHost')}
description={t('pages.settings.smtpHostDesc')}
>
<Input
value={allSetting.smtpHost}
placeholder="smtp.gmail.com"
onChange={(e) => updateSetting({ smtpHost: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpPort')}
badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />}
description={t('pages.settings.smtpPortDesc')}
>
<InputNumber
value={allSetting.smtpPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ smtpPort: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpUsername')}
description={t('pages.settings.smtpUsernameDesc')}
>
<Input
value={allSetting.smtpUsername}
placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpUsername: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpPassword')}
description={
allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword
? t('pages.settings.smtpPasswordConfigured')
: t('pages.settings.smtpPasswordDesc')
}
>
<SecretInput
value={allSetting.smtpPassword}
configured={allSetting.hasSmtpPassword}
clearArmed={allSetting.clearSmtpPassword}
placeholder={t('pages.settings.smtpPasswordPlaceholder')}
onChange={(v) => updateSetting({ smtpPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpFrom')}
description={t('pages.settings.smtpFromDesc')}
>
<Input
value={allSetting.smtpFrom}
placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpFrom: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpFromName')}
description={t('pages.settings.smtpFromNameDesc')}
>
<Input
value={allSetting.smtpFromName}
placeholder="3x-ui"
onChange={(e) => updateSetting({ smtpFromName: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpTo')}
description={t('pages.settings.smtpToDesc')}
>
<Input
value={allSetting.smtpTo}
placeholder="admin@example.com, ops@example.com"
onChange={(e) => updateSetting({ smtpTo: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEncryption')}
description={t('pages.settings.smtpEncryptionDesc')}
>
<Select
value={allSetting.smtpEncryptionType}
onChange={(v) => updateSetting({ smtpEncryptionType: v })}
options={[
{ value: 'none', label: t('pages.settings.smtpEncryptionNone') },
{ value: 'starttls', label: t('pages.settings.smtpEncryptionStartTLS') },
{ value: 'tls', label: t('pages.settings.smtpEncryptionTLS') },
]}
style={{ width: '100%' }}
/>
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleTestSmtp}
>
{t('pages.settings.testSmtp')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
title={
testResult.success ? (
t('pages.settings.' + testResult.msg)
) : (
<span>
<b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b>{' '}
{t('pages.settings.' + testResult.msg)}
</span>
)
}
showIcon
closable={{ onClose: () => setTestResult(null) }}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEventBusNotify')}
description={t('pages.settings.smtpEventBusNotifyDesc')}
>
<EmailNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
},
]}
/>
);
}
+553 -291
View File
@@ -1,12 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Input,
InputNumber,
Select,
Switch,
Tabs,
} from 'antd';
import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
import {
ApartmentOutlined,
BellOutlined,
@@ -34,7 +28,6 @@ interface GeneralTabProps {
updateSetting: (patch: Partial<AllSetting>) => void;
}
export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
@@ -49,20 +42,28 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
(async () => {
// /options is the slim picker-shaped endpoint — it skips the heavy
// per-client settings and clientStats payloads that /list ships.
const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<{
tag: string; protocol: string; port: number;
}[]>;
const msg = (await HttpUtil.get('/panel/api/inbounds/options')) as ApiMsg<
{
tag: string;
protocol: string;
port: number;
}[]
>;
if (cancelled) return;
if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(msg.obj.map((ib) => ({
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
value: ib.tag,
})));
setInboundOptions(
msg.obj.map((ib) => ({
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
value: ib.tag,
})),
);
} else {
setInboundOptions([]);
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
@@ -73,7 +74,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
// is injected as a routing rule, so a balancer tag is a valid target
// (it load-balances the panel's own traffic). The geodata picker, by
// contrast, dials a forced tag and can only use a concrete outbound.
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
const msg = (await HttpUtil.post('/panel/api/xray/', undefined, {
silent: true,
})) as ApiMsg<string>;
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
try {
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
@@ -87,7 +90,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
const tag = rec.tag;
if (typeof tag === 'string' && tag) tags.add(tag);
}
const subTags = Array.isArray(payload.subscriptionOutboundTags) ? payload.subscriptionOutboundTags : [];
const subTags = Array.isArray(payload.subscriptionOutboundTags)
? payload.subscriptionOutboundTags
: [];
for (const tag of subTags) {
if (typeof tag === 'string' && tag) tags.add(tag);
}
@@ -106,26 +111,39 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
setBalancerTagList([]);
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
// Outbound tags and balancer tags share one picker. When balancers exist they
// get their own labeled group so it's clear the selection routes through a
// balancer rather than a single outbound.
const outboundOptions = useMemo<
({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
(
| { label: string; value: string }
| { label: string; options: { label: string; value: string }[] }
)[]
>(() => {
const outOpts = outboundTagList.map((tag) => ({ label: tag, value: tag }));
if (balancerTagList.length === 0) return outOpts;
return [
{ label: t('pages.xray.Outbounds'), options: outOpts },
{ label: t('pages.xray.Balancers'), options: balancerTagList.map((tag) => ({ label: tag, value: tag })) },
{
label: t('pages.xray.Balancers'),
options: balancerTagList.map((tag) => ({ label: tag, value: tag })),
},
];
}, [outboundTagList, balancerTagList, t]);
const ldapInboundTagList = useMemo(() => {
const csv = allSetting.ldapInboundTags || '';
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
return csv.length
? csv
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
}, [allSetting.ldapInboundTags]);
function setLdapInboundTagList(list: string[]) {
@@ -138,280 +156,524 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
}
const langOptions = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>{l.icon}</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
() =>
LanguageManager.supportedLanguages.map(
(l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>
{l.icon}
</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
}),
),
})),
[],
);
return (
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.panelListeningIP')} description={t('pages.settings.panelListeningIPDesc')}>
<Input value={allSetting.webListen} onChange={(e) => updateSetting({ webListen: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelListeningDomain')} description={t('pages.settings.panelListeningDomainDesc')}>
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} badge={<DefaultSettingTag settingKey="webPort" value={allSetting.webPort} />} description={t('pages.settings.panelPortDesc')}>
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} badge={<DefaultSettingTag settingKey="sessionMaxAge" value={allSetting.sessionMaxAge} />} description={t('pages.settings.sessionMaxAgeDesc')}>
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trustedProxyCidrs')}
description={t('pages.settings.trustedProxyCidrsDesc')}
>
<Input
value={allSetting.trustedProxyCIDRs}
placeholder="127.0.0.1/32,::1/128"
onChange={(e) => updateSetting({ trustedProxyCIDRs: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ipLimitAllowlist')}
description={t('pages.settings.ipLimitAllowlistDesc')}
>
<Input
value={allSetting.ipLimitAllowlist}
placeholder="203.0.113.10,198.51.100.0/24"
onChange={(e) => updateSetting({ ipLimitAllowlist: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelOutbound')} description={t('pages.settings.panelOutboundDesc')}>
<Select
style={{ width: '100%' }}
allowClear
showSearch
value={allSetting.panelOutbound || undefined}
placeholder={t('pages.settings.panelOutboundPh')}
options={outboundOptions}
onChange={(v) => updateSetting({ panelOutbound: (v as string | undefined) || '' })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} badge={<DefaultSettingTag settingKey="pageSize" value={allSetting.pageSize} />} description={t('pages.settings.pageSizeDesc')}>
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
<Switch checked={allSetting.restartXrayOnClientDisable}
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.language')}>
<Select
value={lang}
onChange={onLangChange}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
</>
),
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />} description={t('pages.settings.expireTimeDiffDesc')}>
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} badge={<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />} description={t('pages.settings.trafficDiffDesc')}>
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
</SettingListItem>
</>
),
},
{
key: '3',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.publicKeyPath')} description={t('pages.settings.publicKeyPathDesc')}>
<Input value={allSetting.webCertFile} onChange={(e) => updateSetting({ webCertFile: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.privateKeyPath')} description={t('pages.settings.privateKeyPathDesc')}>
<Input value={allSetting.webKeyFile} onChange={(e) => updateSetting({ webKeyFile: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '4',
label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformEnable')} description={t('pages.settings.externalTrafficInformEnableDesc')}>
<Switch checked={allSetting.externalTrafficInformEnable}
onChange={(v) => updateSetting({ externalTrafficInformEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformURI')} description={t('pages.settings.externalTrafficInformURIDesc')}>
<Input
value={allSetting.externalTrafficInformURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '5',
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.timeZone')} description={t('pages.settings.timeZoneDesc')}>
<Input value={allSetting.timeLocation} onChange={(e) => updateSetting({ timeLocation: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.datepicker')} description={t('pages.settings.datepickerDescription')}>
<Select
value={allSetting.datepicker || 'gregorian'}
onChange={(v) => updateSetting({ datepicker: v as 'gregorian' | 'jalalian' })}
style={{ width: '100%' }}
options={[
{ value: 'gregorian', label: t('pages.settings.calendarGregorian') },
{ value: 'jalalian', label: t('pages.settings.calendarJalalian') },
]}
/>
</SettingListItem>
</>
),
},
{
key: '6',
label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}>
<Switch checked={allSetting.ldapEnable} onChange={(v) => updateSetting({ ldapEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')} badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}>
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.skipTlsVerify')}
description={t('pages.settings.ldap.skipTlsVerifyDesc')}
>
<Switch
checked={allSetting.ldapInsecureSkipVerify}
disabled={!allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapInsecureSkipVerify: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.bindDn')}>
<Input value={allSetting.ldapBindDN} onChange={(e) => updateSetting({ ldapBindDN: e.target.value })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('password')}
description={allSetting.hasLdapPassword && !allSetting.clearLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
>
<SecretInput
value={allSetting.ldapPassword}
configured={allSetting.hasLdapPassword}
clearArmed={allSetting.clearLdapPassword}
placeholder={t('pages.settings.ldap.passwordPlaceholder')}
onChange={(v) => updateSetting({ ldapPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>
<Input value={allSetting.ldapBaseDN} onChange={(e) => updateSetting({ ldapBaseDN: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userFilter')}>
<Input value={allSetting.ldapUserFilter} onChange={(e) => updateSetting({ ldapUserFilter: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userAttr')}>
<Input value={allSetting.ldapUserAttr} onChange={(e) => updateSetting({ ldapUserAttr: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.vlessField')}>
<Input value={allSetting.ldapVlessField} onChange={(e) => updateSetting({ ldapVlessField: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.flagField')} description={t('pages.settings.ldap.flagFieldDesc')}>
<Input value={allSetting.ldapFlagField} onChange={(e) => updateSetting({ ldapFlagField: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.truthyValues')} description={t('pages.settings.ldap.truthyValuesDesc')}>
<Input value={allSetting.ldapTruthyValues} onChange={(e) => updateSetting({ ldapTruthyValues: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.invertFlag')} description={t('pages.settings.ldap.invertFlagDesc')}>
<Switch checked={allSetting.ldapInvertFlag} onChange={(v) => updateSetting({ ldapInvertFlag: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.syncSchedule')} description={t('pages.settings.ldap.syncScheduleDesc')}>
<Input value={allSetting.ldapSyncCron} onChange={(e) => updateSetting({ ldapSyncCron: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.inboundTags')} description={t('pages.settings.ldap.inboundTagsDesc')}>
<>
<Select
mode="multiple"
value={ldapInboundTagList}
onChange={setLdapInboundTagList}
style={{ width: '100%' }}
options={inboundOptions}
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.panelListeningIP')}
description={t('pages.settings.panelListeningIPDesc')}
>
<Input
value={allSetting.webListen}
onChange={(e) => updateSetting({ webListen: e.target.value })}
/>
{inboundOptions.length === 0 && (
<div className="ldap-no-inbounds">{t('pages.settings.ldap.noInbounds')}</div>
)}
</>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoCreate')}>
<Switch checked={allSetting.ldapAutoCreate} onChange={(v) => updateSetting({ ldapAutoCreate: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')} badge={<DefaultSettingTag settingKey="ldapDefaultTotalGB" value={allSetting.ldapDefaultTotalGB} />}>
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')} badge={<DefaultSettingTag settingKey="ldapDefaultExpiryDays" value={allSetting.ldapDefaultExpiryDays} />}>
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')} badge={<DefaultSettingTag settingKey="ldapDefaultLimitIP" value={allSetting.ldapDefaultLimitIP} />}>
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
</SettingListItem>
</>
),
},
]} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.panelListeningDomain')}
description={t('pages.settings.panelListeningDomainDesc')}
>
<Input
value={allSetting.webDomain}
onChange={(e) => updateSetting({ webDomain: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.panelPort')}
badge={<DefaultSettingTag settingKey="webPort" value={allSetting.webPort} />}
description={t('pages.settings.panelPortDesc')}
>
<InputNumber
value={allSetting.webPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ webPort: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.panelUrlPath')}
description={t('pages.settings.panelUrlPathDesc')}
>
<Input
value={allSetting.webBasePath}
onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.sessionMaxAge')}
badge={
<DefaultSettingTag settingKey="sessionMaxAge" value={allSetting.sessionMaxAge} />
}
description={t('pages.settings.sessionMaxAgeDesc')}
>
<InputNumber
value={allSetting.sessionMaxAge}
min={60}
max={525600}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trustedProxyCidrs')}
description={t('pages.settings.trustedProxyCidrsDesc')}
>
<Input
value={allSetting.trustedProxyCIDRs}
placeholder="127.0.0.1/32,::1/128"
onChange={(e) => updateSetting({ trustedProxyCIDRs: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ipLimitAllowlist')}
description={t('pages.settings.ipLimitAllowlistDesc')}
>
<Input
value={allSetting.ipLimitAllowlist}
placeholder="203.0.113.10,198.51.100.0/24"
onChange={(e) => updateSetting({ ipLimitAllowlist: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.panelOutbound')}
description={t('pages.settings.panelOutboundDesc')}
>
<Select
style={{ width: '100%' }}
allowClear
showSearch
value={allSetting.panelOutbound || undefined}
placeholder={t('pages.settings.panelOutboundPh')}
options={outboundOptions}
onChange={(v) =>
updateSetting({ panelOutbound: (v as string | undefined) || '' })
}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.pageSize')}
badge={<DefaultSettingTag settingKey="pageSize" value={allSetting.pageSize} />}
description={t('pages.settings.pageSizeDesc')}
>
<InputNumber
value={allSetting.pageSize}
min={0}
max={1000}
step={5}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ pageSize: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.restartXrayOnClientDisable')}
description={t('pages.settings.restartXrayOnClientDisableDesc')}
>
<Switch
checked={allSetting.restartXrayOnClientDisable}
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.language')}>
<Select
value={lang}
onChange={onLangChange}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
</>
),
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.expireTimeDiff')}
badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />}
description={t('pages.settings.expireTimeDiffDesc')}
>
<InputNumber
value={allSetting.expireDiff}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ expireDiff: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficDiff')}
badge={
<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />
}
description={t('pages.settings.trafficDiffDesc')}
>
<InputNumber
value={allSetting.trafficDiff}
min={0}
max={100}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))}
/>
</SettingListItem>
</>
),
},
{
key: '3',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.publicKeyPath')}
description={t('pages.settings.publicKeyPathDesc')}
>
<Input
value={allSetting.webCertFile}
onChange={(e) => updateSetting({ webCertFile: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.privateKeyPath')}
description={t('pages.settings.privateKeyPathDesc')}
>
<Input
value={allSetting.webKeyFile}
onChange={(e) => updateSetting({ webKeyFile: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '4',
label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.externalTrafficInformEnable')}
description={t('pages.settings.externalTrafficInformEnableDesc')}
>
<Switch
checked={allSetting.externalTrafficInformEnable}
onChange={(v) => updateSetting({ externalTrafficInformEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.externalTrafficInformURI')}
description={t('pages.settings.externalTrafficInformURIDesc')}
>
<Input
value={allSetting.externalTrafficInformURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '5',
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.timeZone')}
description={t('pages.settings.timeZoneDesc')}
>
<Input
value={allSetting.timeLocation}
onChange={(e) => updateSetting({ timeLocation: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.datepicker')}
description={t('pages.settings.datepickerDescription')}
>
<Select
value={allSetting.datepicker || 'gregorian'}
onChange={(v) => updateSetting({ datepicker: v as 'gregorian' | 'jalalian' })}
style={{ width: '100%' }}
options={[
{ value: 'gregorian', label: t('pages.settings.calendarGregorian') },
{ value: 'jalalian', label: t('pages.settings.calendarJalalian') },
]}
/>
</SettingListItem>
</>
),
},
{
key: '6',
label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}>
<Switch
checked={allSetting.ldapEnable}
onChange={(v) => updateSetting({ ldapEnable: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
<Input
value={allSetting.ldapHost}
onChange={(e) => updateSetting({ ldapHost: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.port')}
badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}
>
<InputNumber
value={allSetting.ldapPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapPort: v }))}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
<Switch
checked={allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapUseTLS: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.skipTlsVerify')}
description={t('pages.settings.ldap.skipTlsVerifyDesc')}
>
<Switch
checked={allSetting.ldapInsecureSkipVerify}
disabled={!allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapInsecureSkipVerify: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.bindDn')}>
<Input
value={allSetting.ldapBindDN}
onChange={(e) => updateSetting({ ldapBindDN: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('password')}
description={
allSetting.hasLdapPassword && !allSetting.clearLdapPassword
? t('pages.settings.ldap.passwordConfigured')
: t('pages.settings.ldap.passwordUnconfigured')
}
>
<SecretInput
value={allSetting.ldapPassword}
configured={allSetting.hasLdapPassword}
clearArmed={allSetting.clearLdapPassword}
placeholder={t('pages.settings.ldap.passwordPlaceholder')}
onChange={(v) => updateSetting({ ldapPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>
<Input
value={allSetting.ldapBaseDN}
onChange={(e) => updateSetting({ ldapBaseDN: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userFilter')}>
<Input
value={allSetting.ldapUserFilter}
onChange={(e) => updateSetting({ ldapUserFilter: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userAttr')}>
<Input
value={allSetting.ldapUserAttr}
onChange={(e) => updateSetting({ ldapUserAttr: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.vlessField')}>
<Input
value={allSetting.ldapVlessField}
onChange={(e) => updateSetting({ ldapVlessField: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.flagField')}
description={t('pages.settings.ldap.flagFieldDesc')}
>
<Input
value={allSetting.ldapFlagField}
onChange={(e) => updateSetting({ ldapFlagField: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.truthyValues')}
description={t('pages.settings.ldap.truthyValuesDesc')}
>
<Input
value={allSetting.ldapTruthyValues}
onChange={(e) => updateSetting({ ldapTruthyValues: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.invertFlag')}
description={t('pages.settings.ldap.invertFlagDesc')}
>
<Switch
checked={allSetting.ldapInvertFlag}
onChange={(v) => updateSetting({ ldapInvertFlag: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.syncSchedule')}
description={t('pages.settings.ldap.syncScheduleDesc')}
>
<Input
value={allSetting.ldapSyncCron}
onChange={(e) => updateSetting({ ldapSyncCron: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.inboundTags')}
description={t('pages.settings.ldap.inboundTagsDesc')}
>
<>
<Select
mode="multiple"
value={ldapInboundTagList}
onChange={setLdapInboundTagList}
style={{ width: '100%' }}
options={inboundOptions}
/>
{inboundOptions.length === 0 && (
<div className="ldap-no-inbounds">{t('pages.settings.ldap.noInbounds')}</div>
)}
</>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoCreate')}>
<Switch
checked={allSetting.ldapAutoCreate}
onChange={(v) => updateSetting({ ldapAutoCreate: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
<Switch
checked={allSetting.ldapAutoDelete}
onChange={(v) => updateSetting({ ldapAutoDelete: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultTotalGb')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultTotalGB"
value={allSetting.ldapDefaultTotalGB}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultTotalGB}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultExpiryDays')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultExpiryDays"
value={allSetting.ldapDefaultExpiryDays}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultExpiryDays}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultIpLimit')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultLimitIP"
value={allSetting.ldapDefaultLimitIP}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultLimitIP}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))}
/>
</SettingListItem>
</>
),
},
]}
/>
);
}
+158 -116
View File
@@ -1,17 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Empty,
Form,
Input,
Modal,
Space,
Spin,
Switch,
Tabs,
message,
} from 'antd';
import { Button, Empty, Form, Input, Modal, Space, Spin, Switch, Tabs, message } from 'antd';
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
@@ -94,27 +83,36 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
setTfa({ ...opts, open: true });
}, []);
const onTfaConfirm = useCallback((success: boolean, code?: string) => {
tfa.onConfirm(success, code);
}, [tfa]);
const onTfaConfirm = useCallback(
(success: boolean, code?: string) => {
tfa.onConfirm(success, code);
},
[tfa],
);
function updateUserField<K extends keyof typeof user>(key: K, value: string) {
setUser((prev) => ({ ...prev, [key]: value }));
}
const sendUpdateUser = useCallback(async (twoFactorCode = '') => {
setUpdating(true);
try {
const msg = await HttpUtil.post('/panel/api/setting/updateUser', { ...user, twoFactorCode }) as ApiMsg;
if (msg?.success) {
await HttpUtil.post('/logout');
const basePath = window.X_UI_BASE_PATH || '/';
window.location.replace(basePath);
const sendUpdateUser = useCallback(
async (twoFactorCode = '') => {
setUpdating(true);
try {
const msg = (await HttpUtil.post('/panel/api/setting/updateUser', {
...user,
twoFactorCode,
})) as ApiMsg;
if (msg?.success) {
await HttpUtil.post('/logout');
const basePath = window.X_UI_BASE_PATH || '/';
window.location.replace(basePath);
}
} finally {
setUpdating(false);
}
} finally {
setUpdating(false);
}
}, [user]);
},
[user],
);
function onUpdateUserClick() {
if (allSetting.twoFactorEnable) {
@@ -135,7 +133,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
const loadApiTokens = useCallback(async () => {
setApiTokensLoading(true);
try {
const msg = await HttpUtil.get('/panel/api/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
const msg = (await HttpUtil.get('/panel/api/setting/apiTokens')) as ApiMsg<ApiTokenRow[]>;
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
} finally {
setApiTokensLoading(false);
@@ -143,7 +141,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
}, []);
useEffect(() => {
loadApiTokens();
}, [loadApiTokens]);
@@ -167,7 +164,9 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
}
setCreating(true);
try {
const msg = await HttpUtil.post('/panel/api/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
const msg = (await HttpUtil.post('/panel/api/setting/apiTokens/create', { name })) as ApiMsg<{
token?: string;
}>;
if (msg?.success) {
setCreateOpen(false);
await loadApiTokens();
@@ -183,13 +182,16 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
function confirmDeleteToken(row: ApiTokenRow) {
modal.confirm({
title: `${t('delete')} "${row.name}"?`,
content: t('pages.settings.security.apiTokenDeleteWarning')
|| 'Any caller using this token will stop authenticating immediately.',
content:
t('pages.settings.security.apiTokenDeleteWarning') ||
'Any caller using this token will stop authenticating immediately.',
okText: t('delete'),
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`, { expectedScope: row.scope }) as ApiMsg;
const msg = (await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`, {
expectedScope: row.scope,
})) as ApiMsg;
if (msg?.success) await loadApiTokens();
},
});
@@ -197,7 +199,10 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
async function toggleTokenEnabled(row: ApiTokenRow) {
const target = !row.enabled;
const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target, expectedScope: row.scope }) as ApiMsg;
const msg = (await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, {
enabled: target,
expectedScope: row.scope,
})) as ApiMsg;
if (msg?.success) {
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
}
@@ -239,7 +244,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
twoFactorToken: '',
twoFactorCode: code || '',
};
const msg = await saveSetting(next) as ApiMsg;
const msg = (await saveSetting(next)) as ApiMsg;
if (msg?.success) {
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '', hasTwoFactorToken: false });
@@ -253,87 +258,118 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
<>
{messageContextHolder}
{modalContextHolder}
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: catTabLabel(<UserOutlined />, t('pages.settings.security.admin'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
<Input value={user.oldUsername} autoComplete="username"
onChange={(e) => updateUserField('oldUsername', e.target.value)} />
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<UserOutlined />, t('pages.settings.security.admin'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
<Input
value={user.oldUsername}
autoComplete="username"
onChange={(e) => updateUserField('oldUsername', e.target.value)}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.currentPassword')}>
<Input.Password
value={user.oldPassword}
autoComplete="current-password"
onChange={(e) => updateUserField('oldPassword', e.target.value)}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.newUsername')}>
<Input
value={user.newUsername}
onChange={(e) => updateUserField('newUsername', e.target.value)}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.newPassword')}>
<Input.Password
value={user.newPassword}
autoComplete="new-password"
onChange={(e) => updateUserField('newPassword', e.target.value)}
/>
</SettingListItem>
<div className="security-actions">
<Space style={{ padding: '0 20px' }}>
<Button type="primary" loading={updating} onClick={onUpdateUserClick}>
{t('confirm')}
</Button>
</Space>
</div>
</>
),
},
{
key: '2',
label: catTabLabel(
<SafetyOutlined />,
t('pages.settings.security.twoFactor'),
isMobile,
),
children: (
<SettingListItem
paddings="small"
title={t('pages.settings.security.twoFactorEnable')}
description={t('pages.settings.security.twoFactorEnableDesc')}
>
<Switch checked={allSetting.twoFactorEnable} onClick={toggleTwoFactor} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.currentPassword')}>
<Input.Password value={user.oldPassword} autoComplete="current-password"
onChange={(e) => updateUserField('oldPassword', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.newUsername')}>
<Input value={user.newUsername}
onChange={(e) => updateUserField('newUsername', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.newPassword')}>
<Input.Password value={user.newPassword} autoComplete="new-password"
onChange={(e) => updateUserField('newPassword', e.target.value)} />
</SettingListItem>
<div className="security-actions">
<Space style={{ padding: '0 20px' }}>
<Button type="primary" loading={updating} onClick={onUpdateUserClick}>
{t('confirm')}
),
},
{
key: '3',
label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile),
children: (
<div className="api-token-section">
<div className="api-token-header">
<p className="api-token-hint">{t('pages.nodes.apiTokenHint')}</p>
<Button type="primary" size="small" onClick={openCreateModal}>
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
</Button>
</Space>
</div>
</>
),
},
{
key: '2',
label: catTabLabel(<SafetyOutlined />, t('pages.settings.security.twoFactor'), isMobile),
children: (
<SettingListItem
paddings="small"
title={t('pages.settings.security.twoFactorEnable')}
description={t('pages.settings.security.twoFactorEnableDesc')}
>
<Switch checked={allSetting.twoFactorEnable} onClick={toggleTwoFactor} />
</SettingListItem>
),
},
{
key: '3',
label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile),
children: (
<div className="api-token-section">
<div className="api-token-header">
<p className="api-token-hint">{t('pages.nodes.apiTokenHint')}</p>
<Button type="primary" size="small" onClick={openCreateModal}>
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
</Button>
</div>
<Spin spinning={apiTokensLoading}>
{!apiTokens.length && !apiTokensLoading && (
<Empty description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'} />
)}
{apiTokens.map((row) => (
<div key={row.id} className={`api-token-row${row.enabled ? '' : ' disabled'}`}>
<div className="api-token-row-head">
<div className="api-token-name-wrap">
<span className="api-token-name">{row.name}</span>
<span className="api-token-created">{formatTokenDate(row.createdAt)}</span>
</div>
<div className="api-token-actions">
<Switch size="small" checked={row.enabled} onChange={() => toggleTokenEnabled(row)} />
<Button size="small" danger type="text" onClick={() => confirmDeleteToken(row)}>
{t('delete')}
</Button>
</div>
<Spin spinning={apiTokensLoading}>
{!apiTokens.length && !apiTokensLoading && (
<Empty
description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'}
/>
)}
{apiTokens.map((row) => (
<div key={row.id} className={`api-token-row${row.enabled ? '' : ' disabled'}`}>
<div className="api-token-row-head">
<div className="api-token-name-wrap">
<span className="api-token-name">{row.name}</span>
<span className="api-token-created">
{formatTokenDate(row.createdAt)}
</span>
</div>
<div className="api-token-actions">
<Switch
size="small"
checked={row.enabled}
onChange={() => toggleTokenEnabled(row)}
/>
<Button
size="small"
danger
type="text"
onClick={() => confirmDeleteToken(row)}
>
{t('delete')}
</Button>
</div>
</div>
</div>
</div>
))}
</Spin>
</div>
),
},
]} />
))}
</Spin>
</div>
),
},
]}
/>
<Modal
open={createOpen}
@@ -349,7 +385,9 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
<Input
value={createName}
maxLength={64}
placeholder={t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'}
placeholder={
t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'
}
onChange={(e) => setCreateName(e.target.value)}
onPressEnter={confirmCreateToken}
/>
@@ -366,12 +404,16 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
cancelButtonProps={{ style: { display: 'none' } }}
>
<p className="api-token-created-notice">
{t('pages.settings.security.apiTokenCreatedNotice')
|| 'Copy this token now. For security it is not stored in readable form and will not be shown again.'}
{t('pages.settings.security.apiTokenCreatedNotice') ||
'Copy this token now. For security it is not stored in readable form and will not be shown again.'}
</p>
<div className="api-token-value-wrap">
<code className="api-token-value">{createdToken?.token}</code>
<Button size="small" type="primary" onClick={() => createdToken && copyToken(createdToken.token)}>
<Button
size="small"
type="primary"
onClick={() => createdToken && copyToken(createdToken.token)}
>
{t('copy')}
</Button>
</div>
+56 -21
View File
@@ -35,7 +35,14 @@ interface ApiMsg {
success?: boolean;
}
const tabSlugs = ['general', 'security', 'telegram', 'email', 'subscription', 'subscription-formats'];
const tabSlugs = [
'general',
'security',
'telegram',
'email',
'subscription',
'subscription-formats',
];
function isIp(h: string): boolean {
if (typeof h !== 'string') return false;
@@ -84,12 +91,10 @@ export default function SettingsPage() {
const [entryIsIP, setEntryIsIP] = useState(false);
useEffect(() => {
const host = window.location.hostname;
setEntryHost(host);
setEntryPort(window.location.port);
setEntryIsIP(isIp(host));
}, []);
const [alertVisible, setAlertVisible] = useState(true);
@@ -99,7 +104,7 @@ export default function SettingsPage() {
function rebuildUrlAfterRestart(): string {
const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting;
const newProtocol = (webCertFile || webKeyFile) ? 'https:' : 'http:';
const newProtocol = webCertFile || webKeyFile ? 'https:' : 'http:';
let base = webBasePath ? webBasePath.replace(/^\//, '') : '';
if (base && !base.endsWith('/')) base += '/';
@@ -144,7 +149,7 @@ export default function SettingsPage() {
onOk: async () => {
setSpinning(true);
try {
const msg = await HttpUtil.post('/panel/api/setting/restartPanel') as ApiMsg;
const msg = (await HttpUtil.post('/panel/api/setting/restartPanel')) as ApiMsg;
if (!msg?.success) return;
await PromiseUtil.sleep(5000);
window.location.replace(rebuildUrlAfterRestart());
@@ -170,7 +175,11 @@ export default function SettingsPage() {
if (allSetting.subEnable) {
let subPath = allSetting.subPath;
if (allSetting.subURI) {
try { subPath = new URL(allSetting.subURI).pathname; } catch { /* noop */ }
try {
subPath = new URL(allSetting.subURI).pathname;
} catch {
/* noop */
}
}
if (subPath === '/sub/') {
out.push(t('pages.settings.warnDefaultSubPath'));
@@ -179,7 +188,11 @@ export default function SettingsPage() {
if (allSetting.subJsonEnable) {
let p = allSetting.subJsonPath;
if (allSetting.subJsonURI) {
try { p = new URL(allSetting.subJsonURI).pathname; } catch { /* noop */ }
try {
p = new URL(allSetting.subJsonURI).pathname;
} catch {
/* noop */
}
}
if (p === '/json/') {
out.push(t('pages.settings.warnDefaultJsonPath'));
@@ -197,12 +210,24 @@ export default function SettingsPage() {
const categoryBody = useMemo(() => {
switch (activeSlug) {
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} saveSetting={savePayload} />;
case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'email': return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'security':
return (
<SecurityTab
allSetting={allSetting}
updateSetting={updateSetting}
saveSetting={savePayload}
/>
);
case 'telegram':
return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'email':
return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription':
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats':
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
}, [activeSlug, allSetting, updateSetting, savePayload]);
@@ -215,7 +240,12 @@ export default function SettingsPage() {
<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" />
) : (
@@ -227,14 +257,16 @@ export default function SettingsPage() {
closable={{ onClose: () => setAlertVisible(false) }}
className="conf-alert"
title={t('pages.settings.securityWarnings')}
description={(
description={
<>
<b>{t('pages.settings.panelExposed')}</b>
<ul>
{confAlerts.map((msg, i) => <li key={i}>{msg}</li>)}
{confAlerts.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</>
)}
}
/>
)}
@@ -247,7 +279,12 @@ export default function SettingsPage() {
<Button type="primary" disabled={saveDisabled} onClick={onSave}>
{t('pages.settings.save')}
</Button>
<Button type="primary" danger disabled={!saveDisabled} onClick={restartPanel}>
<Button
type="primary"
danger
disabled={!saveDisabled}
onClick={restartPanel}
>
{t('pages.settings.restartPanel')}
</Button>
</Space>
@@ -261,9 +298,7 @@ export default function SettingsPage() {
</Col>
<Col span={24}>
<Card hoverable>
{categoryBody}
</Card>
<Card hoverable>{categoryBody}</Card>
</Col>
</Row>
</>
@@ -1,13 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Card,
Input,
InputNumber,
Select,
Switch,
Tabs,
} from 'antd';
import { Card, Input, InputNumber, Select, Switch, Tabs } from 'antd';
import {
FileTextOutlined,
NodeIndexOutlined,
@@ -73,7 +66,10 @@ function readJson<T>(raw: string, fallback: T): T {
}
}
export default function SubscriptionFormatsTab({ allSetting, updateSetting }: SubscriptionFormatsTabProps) {
export default function SubscriptionFormatsTab({
allSetting,
updateSetting,
}: SubscriptionFormatsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
@@ -81,7 +77,8 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
const directEnabled = allSetting.subJsonRules !== '';
const muxObj = useMemo(
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
() =>
muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX,
[allSetting.subJsonMux, muxEnabled],
);
@@ -89,7 +86,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
updateSetting({ subJsonMux: v ? JSON.stringify(DEFAULT_MUX) : '' });
}
function setMuxField<K extends keyof typeof DEFAULT_MUX>(key: K, value: typeof DEFAULT_MUX[K]) {
function setMuxField<K extends keyof typeof DEFAULT_MUX>(key: K, value: (typeof DEFAULT_MUX)[K]) {
const next = { ...muxObj, [key]: value };
updateSetting({ subJsonMux: JSON.stringify(next) });
}
@@ -148,190 +145,267 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
}
return (
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<div className="subscription-format-sections">
{allSetting.subJsonEnable && (
<Card
size="small"
className="subscription-format-card"
title={(
<span className="subscription-format-card-title">
<FileTextOutlined />
{t('pages.settings.subJsonEnableTitle')}
</span>
)}
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<div className="subscription-format-sections">
{allSetting.subJsonEnable && (
<Card
size="small"
className="subscription-format-card"
title={
<span className="subscription-format-card-title">
<FileTextOutlined />
{t('pages.settings.subJsonEnableTitle')}
</span>
}
>
<SettingListItem
paddings="small"
title={<>JSON {t('pages.settings.subPath')}</>}
description={t('pages.settings.subPathDesc')}
>
<Input
value={allSetting.subJsonPath}
placeholder="/json/"
onChange={(e) => updateSetting({ subJsonPath: sanitizePath(e.target.value) })}
onBlur={() =>
updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) })
}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={<>JSON {t('pages.settings.subURI')}</>}
description={t('pages.settings.subURIDesc')}
>
<Input
value={allSetting.subJsonURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonAlwaysArray')}
description={t('pages.settings.subJsonAlwaysArrayDesc')}
>
<Switch
checked={allSetting.subJsonAlwaysArray}
onChange={(value) => updateSetting({ subJsonAlwaysArray: value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonAutoDetect')}
description={t('pages.settings.subJsonAutoDetectDesc')}
>
<Switch
checked={allSetting.subJsonAutoDetect}
onChange={(v) => updateSetting({ subJsonAutoDetect: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonUserAgentRegex')}
description={t('pages.settings.subJsonUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subJsonUserAgentRegex}
placeholder="(?i)^myclient([ /]|$)"
onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
{allSetting.subClashEnable && (
<Card
size="small"
className="subscription-format-card"
title={
<span className="subscription-format-card-title">
<NodeIndexOutlined />
{t('pages.settings.subClashEnableTitle')}
</span>
}
>
<SettingListItem
paddings="small"
title={<>Clash {t('pages.settings.subPath')}</>}
description={t('pages.settings.subPathDesc')}
>
<Input
value={allSetting.subClashPath}
placeholder="/clash/"
onChange={(e) =>
updateSetting({ subClashPath: sanitizePath(e.target.value) })
}
onBlur={() =>
updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) })
}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={<>Clash {t('pages.settings.subURI')}</>}
description={t('pages.settings.subURIDesc')}
>
<Input
value={allSetting.subClashURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subClashURI: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashAutoDetect')}
description={t('pages.settings.subClashAutoDetectDesc')}
>
<Switch
checked={allSetting.subClashAutoDetect}
onChange={(v) => updateSetting({ subClashAutoDetect: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashUserAgentRegex')}
description={t('pages.settings.subClashUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subClashUserAgentRegex}
placeholder="(?i)(clash|mihomo)"
onChange={(value) => updateSetting({ subClashUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
</div>
),
},
{
key: '2',
label: catTabLabel(
<RocketOutlined />,
t('pages.settings.subFormats.finalMask'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subFormats.finalMask')}
description={t('pages.settings.subFormats.finalMaskDesc')}
/>
<SubJsonFinalMaskForm
value={allSetting.subJsonFinalMask}
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
/>
</>
),
},
{
key: '3',
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.mux')}
description={t('pages.settings.muxDesc')}
>
<SettingListItem paddings="small" title={<>JSON {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subJsonPath}
placeholder="/json/"
onChange={(e) => updateSetting({ subJsonPath: sanitizePath(e.target.value) })}
onBlur={() => updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={<>JSON {t('pages.settings.subURI')}</>} description={t('pages.settings.subURIDesc')}>
<Input
value={allSetting.subJsonURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonAlwaysArray')}
description={t('pages.settings.subJsonAlwaysArrayDesc')}
>
<Switch checked={allSetting.subJsonAlwaysArray} onChange={(value) => updateSetting({ subJsonAlwaysArray: value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subJsonAutoDetect')} description={t('pages.settings.subJsonAutoDetectDesc')}>
<Switch checked={allSetting.subJsonAutoDetect} onChange={(v) => updateSetting({ subJsonAutoDetect: v })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonUserAgentRegex')}
description={t('pages.settings.subJsonUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subJsonUserAgentRegex}
placeholder="(?i)^myclient([ /]|$)"
onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
{allSetting.subClashEnable && (
<Card
size="small"
className="subscription-format-card"
title={(
<span className="subscription-format-card-title">
<NodeIndexOutlined />
{t('pages.settings.subClashEnableTitle')}
</span>
)}
<Switch checked={muxEnabled} onChange={setMuxEnabled} />
</SettingListItem>
{muxEnabled && (
<div className="format-settings">
<SettingListItem
paddings="small"
title={t('pages.settings.subFormats.concurrency')}
>
<InputNumber
value={muxObj.concurrency}
min={-1}
max={1024}
style={{ width: '100%' }}
onChange={onNumber((v) => setMuxField('concurrency', v))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subFormats.xudpConcurrency')}
>
<InputNumber
value={muxObj.xudpConcurrency}
min={-1}
max={1024}
style={{ width: '100%' }}
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subFormats.xudpUdp443')}
>
<Select
value={muxObj.xudpProxyUDP443}
style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpProxyUDP443', v)}
options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
</div>
)}
</>
),
},
{
key: '4',
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.direct')}
description={t('pages.settings.directDesc')}
>
<SettingListItem paddings="small" title={<>Clash {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subClashPath}
placeholder="/clash/"
onChange={(e) => updateSetting({ subClashPath: sanitizePath(e.target.value) })}
onBlur={() => updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={<>Clash {t('pages.settings.subURI')}</>} description={t('pages.settings.subURIDesc')}>
<Input
value={allSetting.subClashURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subClashURI: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashAutoDetect')}
description={t('pages.settings.subClashAutoDetectDesc')}
>
<Switch
checked={allSetting.subClashAutoDetect}
onChange={(v) => updateSetting({ subClashAutoDetect: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashUserAgentRegex')}
description={t('pages.settings.subClashUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subClashUserAgentRegex}
placeholder="(?i)(clash|mihomo)"
onChange={(value) => updateSetting({ subClashUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
</div>
),
},
{
key: '2',
label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
<SubJsonFinalMaskForm
value={allSetting.subJsonFinalMask}
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
/>
</>
),
},
{
key: '3',
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.mux')} description={t('pages.settings.muxDesc')}>
<Switch checked={muxEnabled} onChange={setMuxEnabled} />
</SettingListItem>
{muxEnabled && (
<div className="format-settings">
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={onNumber((v) => setMuxField('concurrency', v))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
<Select
value={muxObj.xudpProxyUDP443}
style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpProxyUDP443', v)}
options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
</div>
)}
</>
),
},
{
key: '4',
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.direct')} description={t('pages.settings.directDesc')}>
<Switch checked={directEnabled} onChange={setDirectEnabled} />
</SettingListItem>
{directEnabled && (
<div className="format-settings">
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select
mode="tags"
value={directIPs}
style={{ width: '100%' }}
onChange={setDirectIPs}
options={directIPsOptions}
/>
</SettingListItem>
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} {t('domainName')}</>}>
<Select
mode="tags"
value={directDomains}
style={{ width: '100%' }}
onChange={setDirectDomains}
options={directDomainsOptions}
/>
</SettingListItem>
</div>
)}
</>
),
},
]} />
<Switch checked={directEnabled} onChange={setDirectEnabled} />
</SettingListItem>
{directEnabled && (
<div className="format-settings">
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select
mode="tags"
value={directIPs}
style={{ width: '100%' }}
onChange={setDirectIPs}
options={directIPsOptions}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={
<>
{t('pages.settings.direct')} {t('domainName')}
</>
}
>
<Select
mode="tags"
value={directDomains}
style={{ width: '100%' }}
onChange={setDirectDomains}
options={directDomainsOptions}
/>
</SettingListItem>
</div>
)}
</>
),
},
]}
/>
);
}
@@ -1,5 +1,13 @@
import { Alert, Button, Input, InputNumber, Switch, Tabs, Tag } from 'antd';
import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import {
BranchesOutlined,
CompassOutlined,
IdcardOutlined,
InfoCircleOutlined,
NodeIndexOutlined,
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import type { AllSetting } from '@/models/setting';
@@ -17,232 +25,390 @@ interface SubscriptionGeneralTabProps {
const isRemoteRoutingSource = (value: string) => /^https:\/\/\S+$/i.test(value.trim());
const remoteSourceBadge = (value: string) => (
isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined
);
const remoteSourceBadge = (value: string) =>
isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined;
export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
export default function SubscriptionGeneralTab({
allSetting,
updateSetting,
}: SubscriptionGeneralTabProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { isMobile } = useMediaQuery();
return (
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subEnable')} description={t('pages.settings.subEnableDesc')}>
<Switch checked={allSetting.subEnable} onChange={(v) => updateSetting({ subEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subJsonEnableTitle')} description={t('pages.settings.subJsonEnable')}>
<Switch checked={allSetting.subJsonEnable} onChange={(v) => updateSetting({ subJsonEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableTitle')}>
<Switch checked={allSetting.subClashEnable} onChange={(v) => updateSetting({ subClashEnable: v })} />
</SettingListItem>
{(allSetting.subJsonEnable || allSetting.subClashEnable) && (
<Alert
type="info"
showIcon
style={{ margin: '12px 20px' }}
title={t('pages.settings.subFormatsTipTitle')}
description={t('pages.settings.subFormatsTipDesc')}
action={(
<Button size="small" onClick={() => navigate('/settings#subscription-formats')}>
{t('pages.settings.subFormatsTipAction')}
</Button>
)}
/>
)}
<SettingListItem paddings="small" title={t('pages.settings.subListen')} description={t('pages.settings.subListenDesc')}>
<Input value={allSetting.subListen} onChange={(e) => updateSetting({ subListen: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subPort')} badge={<DefaultSettingTag settingKey="subPort" value={allSetting.subPort} />} description={t('pages.settings.subPortDesc')}>
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subPath}
placeholder="/sub/"
onChange={(e) => updateSetting({ subPath: sanitizePath(e.target.value) })}
onBlur={() => updateSetting({ subPath: normalizePath(allSetting.subPath) })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subURI')} description={t('pages.settings.subURIDesc')}>
<Input value={allSetting.subURI} placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subURI: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '2',
label: catTabLabel(<InfoCircleOutlined />, t('pages.settings.information'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subEncrypt')} description={t('pages.settings.subEncryptDesc')}>
<Switch checked={allSetting.subEncrypt} onChange={(v) => updateSetting({ subEncrypt: v })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.remarkTemplate')}
description={t('pages.settings.remarkTemplateDesc')}
>
<RemarkTemplateField
value={allSetting.remarkTemplate}
onChange={(v) => updateSetting({ remarkTemplate: v })}
maxLength={256}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subShowIdentityOnAllLinks')}
description={t('pages.settings.subShowIdentityOnAllLinksDesc')}
>
<Switch
checked={allSetting.subShowIdentityOnAllLinks}
onChange={(v) => updateSetting({ subShowIdentityOnAllLinks: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} badge={<DefaultSettingTag settingKey="subUpdates" value={allSetting.subUpdates} />} description={t('pages.settings.subUpdatesDesc')}>
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
</SettingListItem>
</>
),
},
{
key: '3',
label: catTabLabel(<IdcardOutlined />, t('pages.settings.profile'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
<RemarkTemplateField
value={allSetting.subTitle}
onChange={(v) => updateSetting({ subTitle: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subSupportUrl')} description={t('pages.settings.subSupportUrlDesc')}>
<RemarkTemplateField
value={allSetting.subSupportUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subSupportUrl: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subProfileUrl')} description={t('pages.settings.subProfileUrlDesc')}>
<RemarkTemplateField
value={allSetting.subProfileUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subProfileUrl: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subAnnounce')} description={t('pages.settings.subAnnounceDesc')}>
<RemarkTemplateField
value={allSetting.subAnnounce}
onChange={(v) => updateSetting({ subAnnounce: v })}
multiline
rows={3}
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subThemeDir')}
description={(
<>
{t('pages.settings.subThemeDirDesc')}{' '}
<a
href="https://github.com/MHSanaei/3x-ui/blob/main/docs/custom-subscription-templates.md"
target="_blank"
rel="noopener noreferrer"
>
{t('pages.settings.subThemeDirDocs')}
</a>
</>
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subEnable')}
description={t('pages.settings.subEnableDesc')}
>
<Switch
checked={allSetting.subEnable}
onChange={(v) => updateSetting({ subEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonEnableTitle')}
description={t('pages.settings.subJsonEnable')}
>
<Switch
checked={allSetting.subJsonEnable}
onChange={(v) => updateSetting({ subJsonEnable: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableTitle')}>
<Switch
checked={allSetting.subClashEnable}
onChange={(v) => updateSetting({ subClashEnable: v })}
/>
</SettingListItem>
{(allSetting.subJsonEnable || allSetting.subClashEnable) && (
<Alert
type="info"
showIcon
style={{ margin: '12px 20px' }}
title={t('pages.settings.subFormatsTipTitle')}
description={t('pages.settings.subFormatsTipDesc')}
action={
<Button size="small" onClick={() => navigate('/settings#subscription-formats')}>
{t('pages.settings.subFormatsTipAction')}
</Button>
}
/>
)}
>
<Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/"
onChange={(e) => updateSetting({ subThemeDir: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '4',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subCertPath')} description={t('pages.settings.subCertPathDesc')}>
<Input value={allSetting.subCertFile} onChange={(e) => updateSetting({ subCertFile: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subKeyPath')} description={t('pages.settings.subKeyPathDesc')}>
<Input value={allSetting.subKeyFile} onChange={(e) => updateSetting({ subKeyFile: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '5',
label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
<Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subRoutingRules')} badge={remoteSourceBadge(allSetting.subRoutingRules)} description={t('pages.settings.subRoutingRulesDesc')}>
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subHideSettings')} description={t('pages.settings.subHideSettingsDesc')}>
<Switch checked={allSetting.subHideSettings} onChange={(v) => updateSetting({ subHideSettings: v })} />
</SettingListItem>
</>
),
},
{
key: '6',
label: catTabLabel(<NodeIndexOutlined />, 'Clash / Mihomo', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
<Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} badge={remoteSourceBadge(allSetting.subClashRules)} description={t('pages.settings.subClashRoutingRulesDesc')}>
<Input.TextArea
value={allSetting.subClashRules}
rows={8}
placeholder={'https://.../routing.yaml\n\nor inline rules:\nGEOSITE,category-ir,DIRECT'}
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '7',
label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subIncyEnableRouting')} description={t('pages.settings.subIncyEnableRoutingDesc')}>
<Switch checked={allSetting.subIncyEnableRouting} onChange={(v) => updateSetting({ subIncyEnableRouting: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subIncyRoutingRules')} badge={remoteSourceBadge(allSetting.subIncyRoutingRules)} description={t('pages.settings.subIncyRoutingRulesDesc')}>
<Input.TextArea value={allSetting.subIncyRoutingRules} placeholder="incy://routing/onadd/... or https://.../DEFAULT.JSON"
onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })} />
</SettingListItem>
</>
),
},
]} />
<SettingListItem
paddings="small"
title={t('pages.settings.subListen')}
description={t('pages.settings.subListenDesc')}
>
<Input
value={allSetting.subListen}
onChange={(e) => updateSetting({ subListen: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subDomain')}
description={t('pages.settings.subDomainDesc')}
>
<Input
value={allSetting.subDomain}
onChange={(e) => updateSetting({ subDomain: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subPort')}
badge={<DefaultSettingTag settingKey="subPort" value={allSetting.subPort} />}
description={t('pages.settings.subPortDesc')}
>
<InputNumber
value={allSetting.subPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subPort: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subPath')}
description={t('pages.settings.subPathDesc')}
>
<Input
value={allSetting.subPath}
placeholder="/sub/"
onChange={(e) => updateSetting({ subPath: sanitizePath(e.target.value) })}
onBlur={() => updateSetting({ subPath: normalizePath(allSetting.subPath) })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subURI')}
description={t('pages.settings.subURIDesc')}
>
<Input
value={allSetting.subURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subURI: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '2',
label: catTabLabel(<InfoCircleOutlined />, t('pages.settings.information'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subEncrypt')}
description={t('pages.settings.subEncryptDesc')}
>
<Switch
checked={allSetting.subEncrypt}
onChange={(v) => updateSetting({ subEncrypt: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.remarkTemplate')}
description={t('pages.settings.remarkTemplateDesc')}
>
<RemarkTemplateField
value={allSetting.remarkTemplate}
onChange={(v) => updateSetting({ remarkTemplate: v })}
maxLength={256}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subShowIdentityOnAllLinks')}
description={t('pages.settings.subShowIdentityOnAllLinksDesc')}
>
<Switch
checked={allSetting.subShowIdentityOnAllLinks}
onChange={(v) => updateSetting({ subShowIdentityOnAllLinks: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subUpdates')}
badge={<DefaultSettingTag settingKey="subUpdates" value={allSetting.subUpdates} />}
description={t('pages.settings.subUpdatesDesc')}
>
<InputNumber
value={allSetting.subUpdates}
min={0}
max={525600}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subUpdates: v }))}
/>
</SettingListItem>
</>
),
},
{
key: '3',
label: catTabLabel(<IdcardOutlined />, t('pages.settings.profile'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subTitle')}
description={t('pages.settings.subTitleDesc')}
>
<RemarkTemplateField
value={allSetting.subTitle}
onChange={(v) => updateSetting({ subTitle: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subSupportUrl')}
description={t('pages.settings.subSupportUrlDesc')}
>
<RemarkTemplateField
value={allSetting.subSupportUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subSupportUrl: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subProfileUrl')}
description={t('pages.settings.subProfileUrlDesc')}
>
<RemarkTemplateField
value={allSetting.subProfileUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subProfileUrl: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subAnnounce')}
description={t('pages.settings.subAnnounceDesc')}
>
<RemarkTemplateField
value={allSetting.subAnnounce}
onChange={(v) => updateSetting({ subAnnounce: v })}
multiline
rows={3}
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subThemeDir')}
description={
<>
{t('pages.settings.subThemeDirDesc')}{' '}
<a
href="https://github.com/MHSanaei/3x-ui/blob/main/docs/custom-subscription-templates.md"
target="_blank"
rel="noopener noreferrer"
>
{t('pages.settings.subThemeDirDocs')}
</a>
</>
}
>
<Input
value={allSetting.subThemeDir}
placeholder="/etc/3x-ui/sub_templates/my-theme/"
onChange={(e) => updateSetting({ subThemeDir: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '4',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subCertPath')}
description={t('pages.settings.subCertPathDesc')}
>
<Input
value={allSetting.subCertFile}
onChange={(e) => updateSetting({ subCertFile: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subKeyPath')}
description={t('pages.settings.subKeyPathDesc')}
>
<Input
value={allSetting.subKeyFile}
onChange={(e) => updateSetting({ subKeyFile: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '5',
label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subEnableRouting')}
description={t('pages.settings.subEnableRoutingDesc')}
>
<Switch
checked={allSetting.subEnableRouting}
onChange={(v) => updateSetting({ subEnableRouting: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subRoutingRules')}
badge={remoteSourceBadge(allSetting.subRoutingRules)}
description={t('pages.settings.subRoutingRulesDesc')}
>
<Input.TextArea
value={allSetting.subRoutingRules}
placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subHideSettings')}
description={t('pages.settings.subHideSettingsDesc')}
>
<Switch
checked={allSetting.subHideSettings}
onChange={(v) => updateSetting({ subHideSettings: v })}
/>
</SettingListItem>
</>
),
},
{
key: '6',
label: catTabLabel(<NodeIndexOutlined />, 'Clash / Mihomo', isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashEnableRouting')}
description={t('pages.settings.subClashEnableRoutingDesc')}
>
<Switch
checked={allSetting.subClashEnableRouting}
onChange={(v) => updateSetting({ subClashEnableRouting: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashRoutingRules')}
badge={remoteSourceBadge(allSetting.subClashRules)}
description={t('pages.settings.subClashRoutingRulesDesc')}
>
<Input.TextArea
value={allSetting.subClashRules}
rows={8}
placeholder={
'https://.../routing.yaml\n\nor inline rules:\nGEOSITE,category-ir,DIRECT'
}
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '7',
label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyEnableRouting')}
description={t('pages.settings.subIncyEnableRoutingDesc')}
>
<Switch
checked={allSetting.subIncyEnableRouting}
onChange={(v) => updateSetting({ subIncyEnableRouting: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyRoutingRules')}
badge={remoteSourceBadge(allSetting.subIncyRoutingRules)}
description={t('pages.settings.subIncyRoutingRulesDesc')}
>
<Input.TextArea
value={allSetting.subIncyRoutingRules}
placeholder="incy://routing/onadd/... or https://.../DEFAULT.JSON"
onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })}
/>
</SettingListItem>
</>
),
},
]}
/>
);
}
+164 -92
View File
@@ -39,7 +39,12 @@ function parseRunTime(raw: string): RunTime {
const v = (raw ?? '').trim();
const m = v.match(EVERY_RE);
if (m) {
return { mode: 'every', num: Math.max(1, Number(m[1]) || 1), unit: m[2].toLowerCase() as Unit, custom: '' };
return {
mode: 'every',
num: Math.max(1, Number(m[1]) || 1),
unit: m[2].toLowerCase() as Unit,
custom: '',
};
}
if ((MACROS as string[]).includes(v)) {
return { mode: v as Macro, num: 1, unit: 'h', custom: '' };
@@ -60,17 +65,22 @@ function composeRunTime(s: RunTime): string {
// edit (and one that the 6-field parser accepts).
function toCrontab(s: RunTime): string {
switch (s.mode) {
case '@hourly': return '0 0 * * * *';
case '@daily': return '0 0 0 * * *';
case '@weekly': return '0 0 0 * * 0';
case '@monthly': return '0 0 0 1 * *';
case '@hourly':
return '0 0 * * * *';
case '@daily':
return '0 0 0 * * *';
case '@weekly':
return '0 0 0 * * 0';
case '@monthly':
return '0 0 0 1 * *';
case 'every': {
const n = Math.max(1, s.num || 1);
if (s.unit === 's') return `*/${n} * * * * *`;
if (s.unit === 'm') return `0 */${n} * * * *`;
return `0 0 */${n} * * *`;
}
default: return s.custom;
default:
return s.custom;
}
}
@@ -160,106 +170,168 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
setTestLoading(true);
setTestResult(null);
try {
const res = await HttpUtil.post('/panel/api/setting/testTgBot') as { success?: boolean; msg?: string };
const res = (await HttpUtil.post('/panel/api/setting/testTgBot')) as {
success?: boolean;
msg?: string;
};
setTestResult({ success: !!res.success, msg: res.msg || '' });
} catch (e: unknown) {
setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
setTestResult({
success: false,
msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'),
});
} finally {
setTestLoading(false);
}
}
const langOptions = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>{l.icon}</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
() =>
LanguageManager.supportedLanguages.map(
(l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>
{l.icon}
</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
}),
),
})),
[],
);
return (
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.telegramBotEnable')} description={t('pages.settings.telegramBotEnableDesc')}>
<Switch checked={allSetting.tgBotEnable} onChange={(v) => updateSetting({ tgBotEnable: v })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramToken')}
description={allSetting.hasTgBotToken && !allSetting.clearTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
>
<SecretInput
value={allSetting.tgBotToken}
configured={allSetting.hasTgBotToken}
clearArmed={allSetting.clearTgBotToken}
placeholder={t('pages.settings.telegramTokenPlaceholder')}
onChange={(v) => updateSetting({ tgBotToken: v })}
onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.telegramChatId')} description={t('pages.settings.telegramChatIdDesc')}>
<Input value={allSetting.tgBotChatId} onChange={(e) => updateSetting({ tgBotChatId: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.telegramBotLanguage')}>
<Select
value={allSetting.tgLang}
onChange={(v) => updateSetting({ tgLang: v })}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.telegramAPIServer')} description={t('pages.settings.telegramAPIServerDesc')}>
<Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestTgBot}>
{t('pages.settings.testTgBot')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
title={testResult.msg}
showIcon
closable={{ onClose: () => setTestResult(null) }}
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramBotEnable')}
description={t('pages.settings.telegramBotEnableDesc')}
>
<Switch
checked={allSetting.tgBotEnable}
onChange={(v) => updateSetting({ tgBotEnable: v })}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}>
<NotifyTimeField value={allSetting.tgRunTime} onChange={(v) => updateSetting({ tgRunTime: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
<Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
</SettingListItem>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.tgEventBusNotify')} description={t('pages.settings.tgEventBusNotifyDesc')}>
<TelegramNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
},
]} />
<SettingListItem
paddings="small"
title={t('pages.settings.telegramToken')}
description={
allSetting.hasTgBotToken && !allSetting.clearTgBotToken
? t('pages.settings.telegramTokenConfigured')
: t('pages.settings.telegramTokenDesc')
}
>
<SecretInput
value={allSetting.tgBotToken}
configured={allSetting.hasTgBotToken}
clearArmed={allSetting.clearTgBotToken}
placeholder={t('pages.settings.telegramTokenPlaceholder')}
onChange={(v) => updateSetting({ tgBotToken: v })}
onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramChatId')}
description={t('pages.settings.telegramChatIdDesc')}
>
<Input
value={allSetting.tgBotChatId}
onChange={(e) => updateSetting({ tgBotChatId: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.telegramBotLanguage')}>
<Select
value={allSetting.tgLang}
onChange={(v) => updateSetting({ tgLang: v })}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramAPIServer')}
description={t('pages.settings.telegramAPIServerDesc')}
>
<Input
value={allSetting.tgBotAPIServer}
placeholder="https://api.example.com"
onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })}
/>
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleTestTgBot}
>
{t('pages.settings.testTgBot')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
title={testResult.msg}
showIcon
closable={{ onClose: () => setTestResult(null) }}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramNotifyTime')}
description={t('pages.settings.telegramNotifyTimeDesc')}
>
<NotifyTimeField
value={allSetting.tgRunTime}
onChange={(v) => updateSetting({ tgRunTime: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.tgNotifyBackup')}
description={t('pages.settings.tgNotifyBackupDesc')}
>
<Switch
checked={allSetting.tgBotBackup}
onChange={(v) => updateSetting({ tgBotBackup: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.tgEventBusNotify')}
description={t('pages.settings.tgEventBusNotifyDesc')}
>
<TelegramNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
},
]}
/>
);
}
+62 -44
View File
@@ -37,7 +37,7 @@ export default function TwoFactorModal({
useEffect(() => {
if (!open) return;
setEnteredCode('');
totpRef.current = null;
setQrValue('');
@@ -53,7 +53,6 @@ export default function TwoFactorModal({
totpRef.current = totp;
setQrValue(totp.toString());
}
}, [open, token]);
function close(success: boolean, code = '') {
@@ -65,7 +64,9 @@ export default function TwoFactorModal({
function onOk() {
const codeOk = TotpCodeSchema.safeParse(enteredCode);
if (!codeOk.success) {
messageApi.error(t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'));
messageApi.error(
t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'),
);
return;
}
if (type === 'confirm' && !token) {
@@ -97,49 +98,66 @@ export default function TwoFactorModal({
title={title}
closable
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>{t('cancel')}</Button>,
<Button key="ok" type="primary" disabled={!TotpCodeSchema.safeParse(enteredCode).success} onClick={onOk}>
{t('confirm')}
</Button>,
]}
>
{type === 'set' ? (
<>
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
<Divider />
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
<div
className="qr-wrap"
role="button"
tabIndex={0}
aria-label={t('copy')}
onClick={copyToken}
onKeyDown={activateOnKey(copyToken)}
footer={[
<Button key="cancel" onClick={onCancel}>
{t('cancel')}
</Button>,
<Button
key="ok"
type="primary"
disabled={!TotpCodeSchema.safeParse(enteredCode).success}
onClick={onOk}
>
<QRCode
className="qr-code"
value={qrValue}
size={180}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
errorLevel="L"
title={t('copy')}
{t('confirm')}
</Button>,
]}
>
{type === 'set' ? (
<>
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
<Divider />
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
<div
className="qr-wrap"
role="button"
tabIndex={0}
aria-label={t('copy')}
onClick={copyToken}
onKeyDown={activateOnKey(copyToken)}
>
<QRCode
className="qr-code"
value={qrValue}
size={180}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
errorLevel="L"
title={t('copy')}
/>
<span className="qr-token">{token}</span>
</div>
<Divider />
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
<Input
value={enteredCode}
onChange={(e) => setEnteredCode(e.target.value)}
style={{ width: '100%' }}
aria-label={t('twoFactorCode')}
/>
<span className="qr-token">{token}</span>
</div>
<Divider />
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
</>
) : (
<>
<p>{description}</p>
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
</>
)}
</>
) : (
<>
<p>{description}</p>
<Input
value={enteredCode}
onChange={(e) => setEnteredCode(e.target.value)}
style={{ width: '100%' }}
aria-label={t('twoFactorCode')}
/>
</>
)}
</Modal>
</>
);
+4 -3
View File
@@ -6,9 +6,10 @@ import { Tooltip } from 'antd';
old top tab bar's icons-only behaviour. */
export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode {
if (iconsOnly) {
const labelledIcon = typeof text === 'string' && isValidElement(icon)
? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text })
: icon;
const labelledIcon =
typeof text === 'string' && isValidElement(icon)
? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text })
: icon;
return <Tooltip title={text}>{labelledIcon}</Tooltip>;
}
return (
+2 -1
View File
@@ -2,7 +2,8 @@ export function sanitizePath(input: string): string {
let out = '';
for (const ch of String(input ?? '')) {
const code = ch.charCodeAt(0);
if (ch === ':' || ch === '*' || ch === ' ' || ch === '\\' || code < 0x20 || code === 0x7f) continue;
if (ch === ':' || ch === '*' || ch === ' ' || ch === '\\' || code < 0x20 || code === 0x7f)
continue;
out += ch;
}
return out;

Some files were not shown because too many files have changed in this diff Show More