mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
chore(frontend): antd v6 polish, theme + modal fixes
- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
This commit is contained in:
parent
7a4317086b
commit
886376db7d
54 changed files with 779 additions and 399 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -10,7 +10,6 @@ on:
|
||||||
- "**.mjs"
|
- "**.mjs"
|
||||||
- "**.cjs"
|
- "**.cjs"
|
||||||
- "**.ts"
|
- "**.ts"
|
||||||
- "**.vue"
|
|
||||||
- "**.html"
|
- "**.html"
|
||||||
- "**.css"
|
- "**.css"
|
||||||
- "frontend/package.json"
|
- "frontend/package.json"
|
||||||
|
|
@ -27,7 +26,6 @@ on:
|
||||||
- "**.mjs"
|
- "**.mjs"
|
||||||
- "**.cjs"
|
- "**.cjs"
|
||||||
- "**.ts"
|
- "**.ts"
|
||||||
- "**.vue"
|
|
||||||
- "**.html"
|
- "**.html"
|
||||||
- "**.css"
|
- "**.css"
|
||||||
- "frontend/package.json"
|
- "frontend/package.json"
|
||||||
|
|
|
||||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
|
|
@ -14,7 +14,6 @@ on:
|
||||||
- "**.mjs"
|
- "**.mjs"
|
||||||
- "**.cjs"
|
- "**.cjs"
|
||||||
- "**.ts"
|
- "**.ts"
|
||||||
- "**.vue"
|
|
||||||
- "frontend/package-lock.json"
|
- "frontend/package-lock.json"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
|
|
@ -25,7 +24,6 @@ on:
|
||||||
- "**.mjs"
|
- "**.mjs"
|
||||||
- "**.cjs"
|
- "**.cjs"
|
||||||
- "**.ts"
|
- "**.ts"
|
||||||
- "**.vue"
|
|
||||||
- "frontend/package-lock.json"
|
- "frontend/package-lock.json"
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "18 2 * * 2"
|
- cron: "18 2 * * 2"
|
||||||
|
|
|
||||||
11
frontend/src/components/AppBridge.tsx
Normal file
11
frontend/src/components/AppBridge.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { App } from 'antd';
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
|
|
||||||
|
export default function AppBridge({ children }: { children: React.ReactNode }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
useEffect(() => {
|
||||||
|
setMessageInstance(message);
|
||||||
|
}, [message]);
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
40
frontend/src/components/InputAddon.css
Normal file
40
frontend/src/components/InputAddon.css
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
.input-addon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 11px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 30px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .input-addon,
|
||||||
|
html[data-theme='ultra-dark'] .input-addon {
|
||||||
|
background-color: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: #424242;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-space-compact > .input-addon:not(:first-child) {
|
||||||
|
margin-inline-start: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-space-compact > .input-addon:first-child {
|
||||||
|
border-start-end-radius: 0;
|
||||||
|
border-end-end-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-space-compact > .input-addon:last-child {
|
||||||
|
border-start-start-radius: 0;
|
||||||
|
border-end-start-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-space-compact > .input-addon:not(:first-child):not(:last-child) {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
21
frontend/src/components/InputAddon.tsx
Normal file
21
frontend/src/components/InputAddon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
|
import './InputAddon.css';
|
||||||
|
|
||||||
|
interface InputAddonProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`input-addon ${className}`.trim()}
|
||||||
|
style={style}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/components/SettingListItem.css
Normal file
43
frontend/src/components/SettingListItem.css
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
.setting-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-list-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .setting-list-item,
|
||||||
|
html[data-theme='ultra-dark'] .setting-list-item {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-list-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-list-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-list-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
line-height: 1.5715;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .setting-list-title,
|
||||||
|
html[data-theme='ultra-dark'] .setting-list-title {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .setting-list-description,
|
||||||
|
html[data-theme='ultra-dark'] .setting-list-description {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Col, List, Row } from 'antd';
|
import { Col, Row } from 'antd';
|
||||||
|
import './SettingListItem.css';
|
||||||
|
|
||||||
interface SettingListItemProps {
|
interface SettingListItemProps {
|
||||||
paddings?: 'small' | 'default';
|
paddings?: 'small' | 'default';
|
||||||
|
|
@ -18,15 +19,18 @@ export default function SettingListItem({
|
||||||
}: SettingListItemProps) {
|
}: SettingListItemProps) {
|
||||||
const padding = paddings === 'small' ? '10px 20px' : '20px';
|
const padding = paddings === 'small' ? '10px 20px' : '20px';
|
||||||
return (
|
return (
|
||||||
<List.Item style={{ padding }}>
|
<div className="setting-list-item" style={{ padding }}>
|
||||||
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<List.Item.Meta title={title} description={description} />
|
<div className="setting-list-meta">
|
||||||
|
{title && <div className="setting-list-title">{title}</div>}
|
||||||
|
{description && <div className="setting-list-description">{description}</div>}
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
{control ?? children}
|
{control ?? children}
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</List.Item>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ interface TextModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
|
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
async function copy() {
|
async function copy() {
|
||||||
const ok = await ClipboardManager.copyText(content || '');
|
const ok = await ClipboardManager.copyText(content || '');
|
||||||
if (ok) {
|
if (ok) {
|
||||||
message.success('Copied');
|
messageApi.success('Copied');
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +27,8 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={title}
|
title={title}
|
||||||
|
|
@ -51,5 +54,6 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ function applyDom(isDark: boolean, isUltra: boolean) {
|
||||||
if (msg) msg.className = isDark ? 'dark' : 'light';
|
if (msg) msg.className = isDark ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror the Vue useTheme module: apply current localStorage state at
|
|
||||||
// module load so the document is in the right theme before React mounts.
|
// module load so the document is in the right theme before React mounts.
|
||||||
const initialDark = readBool(STORAGE_DARK, true);
|
const initialDark = readBool(STORAGE_DARK, true);
|
||||||
const initialUltra = readBool(STORAGE_ULTRA, false);
|
const initialUltra = readBool(STORAGE_ULTRA, false);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export async function readyI18n() {
|
||||||
lng: active,
|
lng: active,
|
||||||
fallbackLng: FALLBACK,
|
fallbackLng: FALLBACK,
|
||||||
resources: { [FALLBACK]: { translation: enUS } },
|
resources: { [FALLBACK]: { translation: enUS } },
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
||||||
returnNull: false,
|
returnNull: false,
|
||||||
});
|
});
|
||||||
if (active !== FALLBACK) {
|
if (active !== FALLBACK) {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ function highlightJson(str: string): string {
|
||||||
|
|
||||||
export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) {
|
export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
const highlighted = useMemo(
|
const highlighted = useMemo(
|
||||||
() => (lang === 'json' ? highlightJson(code) : escapeHtml(code)),
|
() => (lang === 'json' ? highlightJson(code) : escapeHtml(code)),
|
||||||
|
|
@ -39,15 +40,16 @@ export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps)
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code);
|
await navigator.clipboard.writeText(code);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
message.success('Copied');
|
messageApi.success('Copied');
|
||||||
window.setTimeout(() => setCopied(false), 2000);
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Copy failed');
|
messageApi.error('Copy failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="code-block-wrapper">
|
<div className="code-block-wrapper">
|
||||||
|
{messageContextHolder}
|
||||||
<div className="code-toolbar">
|
<div className="code-toolbar">
|
||||||
<span className="lang-badge">{lang.toUpperCase()}</span>
|
<span className="lang-badge">{lang.toUpperCase()}</span>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export default function ClientBulkAddModal({
|
||||||
onSaved,
|
onSaved,
|
||||||
}: ClientBulkAddModalProps) {
|
}: ClientBulkAddModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
const [delayedStart, setDelayedStart] = useState(false);
|
const [delayedStart, setDelayedStart] = useState(false);
|
||||||
|
|
@ -153,7 +154,7 @@ export default function ClientBulkAddModal({
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
|
if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
|
||||||
message.error(t('pages.clients.selectInbound'));
|
messageApi.error(t('pages.clients.selectInbound'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const emails = buildEmails();
|
const emails = buildEmails();
|
||||||
|
|
@ -190,9 +191,9 @@ export default function ClientBulkAddModal({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (failed === 0) {
|
if (failed === 0) {
|
||||||
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
|
messageApi.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
|
||||||
} else {
|
} else {
|
||||||
message.warning(firstError
|
messageApi.warning(firstError
|
||||||
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
|
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
|
||||||
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
|
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +205,8 @@ export default function ClientBulkAddModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={t('pages.clients.bulk')}
|
title={t('pages.clients.bulk')}
|
||||||
|
|
@ -333,5 +336,6 @@ export default function ClientBulkAddModal({
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ export default function ClientFormModal({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ClientFormModalProps) {
|
}: ClientFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
|
@ -268,11 +269,11 @@ export default function ClientFormModal({
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
if (!form.email || form.email.trim() === '') {
|
if (!form.email || form.email.trim() === '') {
|
||||||
message.error(`${t('pages.clients.email')} *`);
|
messageApi.error(`${t('pages.clients.email')} *`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isEdit && (!form.inboundIds || form.inboundIds.length === 0)) {
|
if (!isEdit && (!form.inboundIds || form.inboundIds.length === 0)) {
|
||||||
message.error(t('pages.clients.selectInbound'));
|
messageApi.error(t('pages.clients.selectInbound'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const expiryTime = form.delayedStart
|
const expiryTime = form.delayedStart
|
||||||
|
|
@ -324,6 +325,8 @@ export default function ClientFormModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')}
|
title={isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')}
|
||||||
|
|
@ -518,5 +521,6 @@ export default function ClientFormModal({
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export default function ClientInfoModal({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ClientInfoModalProps) {
|
}: ClientInfoModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [links, setLinks] = useState<string[]>([]);
|
const [links, setLinks] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -93,10 +94,12 @@ export default function ClientInfoModal({
|
||||||
async function copyValue(text: string) {
|
async function copyValue(text: string) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const ok = await ClipboardManager.copyText(String(text));
|
const ok = await ClipboardManager.copyText(String(text));
|
||||||
if (ok) message.success(t('copied'));
|
if (ok) messageApi.success(t('copied'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={client ? client.email : t('info')}
|
title={client ? client.email : t('info')}
|
||||||
|
|
@ -290,5 +293,6 @@ export default function ClientInfoModal({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||||
import AppSidebar from '@/components/AppSidebar';
|
import AppSidebar from '@/components/AppSidebar';
|
||||||
import CustomStatistic from '@/components/CustomStatistic';
|
import CustomStatistic from '@/components/CustomStatistic';
|
||||||
import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils';
|
import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils';
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import ClientFormModal from './ClientFormModal';
|
import ClientFormModal from './ClientFormModal';
|
||||||
import ClientInfoModal from './ClientInfoModal';
|
import ClientInfoModal from './ClientInfoModal';
|
||||||
import ClientQrModal from './ClientQrModal';
|
import ClientQrModal from './ClientQrModal';
|
||||||
|
|
@ -86,6 +87,8 @@ export default function ClientsPage() {
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
clients, inbounds, onlines, loading, fetched, subSettings,
|
clients, inbounds, onlines, loading, fetched, subSettings,
|
||||||
|
|
@ -318,7 +321,7 @@ export default function ClientsPage() {
|
||||||
try {
|
try {
|
||||||
const msg = await setEnable(row, next);
|
const msg = await setEnable(row, next);
|
||||||
if (!msg?.success) {
|
if (!msg?.success) {
|
||||||
message.error(msg?.msg || t('somethingWentWrong'));
|
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingEmail(null);
|
setTogglingEmail(null);
|
||||||
|
|
@ -348,14 +351,14 @@ export default function ClientsPage() {
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const msg = await remove(row.email);
|
const msg = await remove(row.email);
|
||||||
if (msg?.success) message.success(t('pages.clients.toasts.deleted'));
|
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onResetTraffic(row: ClientRecord) {
|
function onResetTraffic(row: ClientRecord) {
|
||||||
if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
|
if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
|
||||||
message.warning(t('pages.clients.resetNotPossible'));
|
messageApi.warning(t('pages.clients.resetNotPossible'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
|
|
@ -365,7 +368,7 @@ export default function ClientsPage() {
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const msg = await resetTraffic(row);
|
const msg = await resetTraffic(row);
|
||||||
if (msg?.success) message.success(t('pages.clients.toasts.trafficReset'));
|
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -389,7 +392,7 @@ export default function ClientsPage() {
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const msg = await resetAllTraffics();
|
const msg = await resetAllTraffics();
|
||||||
if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset'));
|
if (msg?.success) messageApi.success(t('pages.clients.toasts.allTrafficsReset'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -405,7 +408,7 @@ export default function ClientsPage() {
|
||||||
const msg = await delDepleted();
|
const msg = await delDepleted();
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
const deleted = msg.obj?.deleted ?? 0;
|
const deleted = msg.obj?.deleted ?? 0;
|
||||||
message.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
|
messageApi.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -434,9 +437,9 @@ export default function ClientsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (failed === 0) {
|
if (failed === 0) {
|
||||||
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
|
messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
|
||||||
} else {
|
} else {
|
||||||
message.warning(firstError
|
messageApi.warning(firstError
|
||||||
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
|
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
|
||||||
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
|
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
|
||||||
}
|
}
|
||||||
|
|
@ -620,6 +623,7 @@ export default function ClientsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass}>
|
||||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||||
|
|
@ -758,6 +762,7 @@ export default function ClientsPage() {
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
pagination={tablePagination}
|
pagination={tablePagination}
|
||||||
size="small"
|
size="small"
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
onChange={onTableChange}
|
onChange={onTableChange}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: (
|
emptyText: (
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
SizeFormatter,
|
SizeFormatter,
|
||||||
Wireguard,
|
Wireguard,
|
||||||
} from '@/utils';
|
} from '@/utils';
|
||||||
|
import InputAddon from '@/components/InputAddon';
|
||||||
import { getRandomRealityTarget } from '@/models/reality-targets';
|
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||||
import {
|
import {
|
||||||
Inbound,
|
Inbound,
|
||||||
|
|
@ -157,6 +158,7 @@ export default function InboundFormModal({
|
||||||
dbInbounds,
|
dbInbounds,
|
||||||
}: InboundFormModalProps) {
|
}: InboundFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const { nodes: availableNodes } = useNodes();
|
const { nodes: availableNodes } = useNodes();
|
||||||
const selectableNodes = useMemo(
|
const selectableNodes = useMemo(
|
||||||
() => (availableNodes || []).filter((n: NodeRecord) => n.enable),
|
() => (availableNodes || []).filter((n: NodeRecord) => n.enable),
|
||||||
|
|
@ -413,8 +415,8 @@ export default function InboundFormModal({
|
||||||
const defaults = deriveFallbackDefaults(child);
|
const defaults = deriveFallbackDefaults(child);
|
||||||
return { ...row, ...defaults };
|
return { ...row, ...defaults };
|
||||||
}));
|
}));
|
||||||
message.success(t('pages.inbounds.fallbacks.rederived') || 'Re-filled from child');
|
messageApi.success(t('pages.inbounds.fallbacks.rederived') || 'Re-filled from child');
|
||||||
}, [dbInbounds, t]);
|
}, [dbInbounds, t, messageApi]);
|
||||||
|
|
||||||
const quickAddAllFallbacks = useCallback(() => {
|
const quickAddAllFallbacks = useCallback(() => {
|
||||||
const masterId = dbInbound?.id;
|
const masterId = dbInbound?.id;
|
||||||
|
|
@ -438,13 +440,13 @@ export default function InboundFormModal({
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
message.success(t('pages.inbounds.fallbacks.quickAdded', { n: added }) || `Added ${added} fallback(s)`);
|
messageApi.success(t('pages.inbounds.fallbacks.quickAdded', { n: added }) || `Added ${added} fallback(s)`);
|
||||||
} else {
|
} else {
|
||||||
message.info(t('pages.inbounds.fallbacks.quickAddedNone') || 'No new eligible inbounds to add');
|
messageApi.info(t('pages.inbounds.fallbacks.quickAddedNone') || 'No new eligible inbounds to add');
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [dbInbound, dbInbounds, t]);
|
}, [dbInbound, dbInbounds, t, messageApi]);
|
||||||
|
|
||||||
const fallbackChildOptions = useMemo(() => {
|
const fallbackChildOptions = useMemo(() => {
|
||||||
const list = dbInbounds || [];
|
const list = dbInbounds || [];
|
||||||
|
|
@ -652,16 +654,16 @@ export default function InboundFormModal({
|
||||||
try {
|
try {
|
||||||
return parseAdvancedSliceOrFallback(rawText, fallback);
|
return parseAdvancedSliceOrFallback(rawText, fallback);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`${label} JSON invalid: ${(e as Error).message}`);
|
messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [messageApi]);
|
||||||
|
|
||||||
const compactAdvancedJson = (raw: string, fallback: string, label: string) => {
|
const compactAdvancedJson = (raw: string, fallback: string, label: string) => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(raw || fallback));
|
return JSON.stringify(JSON.parse(raw || fallback));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`${label} JSON invalid: ${(e as Error).message}`);
|
messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -692,11 +694,11 @@ export default function InboundFormModal({
|
||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`${t('pages.inbounds.advanced.jsonErrorPrefix')}: ${(e as Error).message}`);
|
messageApi.error(`${t('pages.inbounds.advanced.jsonErrorPrefix')}: ${(e as Error).message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}, [t, refresh, parseAdvancedSliceWithLabel]);
|
}, [t, refresh, parseAdvancedSliceWithLabel, messageApi]);
|
||||||
|
|
||||||
const handleTabChange = (next: string) => {
|
const handleTabChange = (next: string) => {
|
||||||
if (activeTabKey === 'advanced' && next !== 'advanced') {
|
if (activeTabKey === 'advanced' && next !== 'advanced') {
|
||||||
|
|
@ -734,23 +736,23 @@ export default function InboundFormModal({
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(next);
|
parsed = JSON.parse(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`${label} JSON invalid: ${(e as Error).message}`);
|
messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const unwrapped = unwrapWrappedObject(parsed, key);
|
const unwrapped = unwrapWrappedObject(parsed, key);
|
||||||
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
|
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
|
||||||
message.error(`${label} JSON must be an object or { ${key}: { ... } }.`);
|
messageApi.error(`${label} JSON must be an object or { ${key}: { ... } }.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
advancedTextRef.current[slice] = JSON.stringify(unwrapped, null, 2);
|
advancedTextRef.current[slice] = JSON.stringify(unwrapped, null, 2);
|
||||||
refresh();
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`${label} JSON invalid: ${(e as Error).message}`);
|
messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const advancedAllValue = useMemo(() => {
|
const advancedAllValue = (() => {
|
||||||
const ib = inboundRef.current;
|
const ib = inboundRef.current;
|
||||||
if (!ib) return '';
|
if (!ib) return '';
|
||||||
try {
|
try {
|
||||||
|
|
@ -769,19 +771,18 @@ export default function InboundFormModal({
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
})();
|
||||||
}, [inboundRef.current, canEnableStream]);
|
|
||||||
|
|
||||||
const setAdvancedAllValue = (next: string) => {
|
const setAdvancedAllValue = (next: string) => {
|
||||||
let parsed: any;
|
let parsed: any;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(next);
|
parsed = JSON.parse(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`All JSON invalid: ${(e as Error).message}`);
|
messageApi.error(`All JSON invalid: ${(e as Error).message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
message.error('All JSON must be an inbound object.');
|
messageApi.error('All JSON must be an inbound object.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ib = inboundRef.current;
|
const ib = inboundRef.current;
|
||||||
|
|
@ -804,7 +805,7 @@ export default function InboundFormModal({
|
||||||
: '{}';
|
: '{}';
|
||||||
refresh();
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`All JSON invalid: ${(e as Error).message}`);
|
messageApi.error(`All JSON invalid: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -919,13 +920,13 @@ export default function InboundFormModal({
|
||||||
{selectableNodes.length > 0 && isNodeEligible && (
|
{selectableNodes.length > 0 && isNodeEligible && (
|
||||||
<Form.Item label={t('pages.inbounds.deployTo')}>
|
<Form.Item label={t('pages.inbounds.deployTo')}>
|
||||||
<Select
|
<Select
|
||||||
value={form.nodeId}
|
value={form.nodeId ?? ''}
|
||||||
disabled={mode === 'edit'}
|
disabled={mode === 'edit'}
|
||||||
placeholder={t('pages.inbounds.localPanel')}
|
placeholder={t('pages.inbounds.localPanel')}
|
||||||
allowClear
|
allowClear
|
||||||
onChange={(v) => { form.nodeId = v ?? null; refresh(); }}
|
onChange={(v) => { form.nodeId = v === '' || v == null ? null : v; refresh(); }}
|
||||||
>
|
>
|
||||||
<Select.Option value={null}>{t('pages.inbounds.localPanel')}</Select.Option>
|
<Select.Option value="">{t('pages.inbounds.localPanel')}</Select.Option>
|
||||||
{selectableNodes.map((n: NodeRecord) => (
|
{selectableNodes.map((n: NodeRecord) => (
|
||||||
<Select.Option key={n.id} value={n.id} disabled={n.status === 'offline'}>
|
<Select.Option key={n.id} value={n.id} disabled={n.status === 'offline'}>
|
||||||
{n.name}{n.status === 'offline' ? ' (offline)' : ''}
|
{n.name}{n.status === 'offline' ? ' (offline)' : ''}
|
||||||
|
|
@ -991,7 +992,7 @@ export default function InboundFormModal({
|
||||||
{t('pages.inbounds.fallbacks.help') || 'When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.'}
|
{t('pages.inbounds.fallbacks.help') || 'When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.'}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
{fallbacks.length === 0 && (
|
{fallbacks.length === 0 && (
|
||||||
<Empty description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'} imageStyle={{ height: 40 }} style={{ margin: '8px 0 12px' }} />
|
<Empty description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'} styles={{ image: { height: 40 } }} style={{ margin: '8px 0 12px' }} />
|
||||||
)}
|
)}
|
||||||
{fallbacks.map((record, index) => (
|
{fallbacks.map((record, index) => (
|
||||||
<div key={record.rowKey} style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}>
|
<div key={record.rowKey} style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}>
|
||||||
|
|
@ -1043,21 +1044,33 @@ export default function InboundFormModal({
|
||||||
{fallbackEditing.has(record.rowKey) && (
|
{fallbackEditing.has(record.rowKey) && (
|
||||||
<Row gutter={8} style={{ marginTop: 8 }}>
|
<Row gutter={8} style={{ marginTop: 8 }}>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<Input addonBefore="SNI" placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
<Space.Compact block>
|
||||||
|
<InputAddon>SNI</InputAddon>
|
||||||
|
<Input placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||||
value={record.name} onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })} />
|
value={record.name} onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })} />
|
||||||
|
</Space.Compact>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={5}>
|
<Col xs={24} md={5}>
|
||||||
<Input addonBefore="ALPN" placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
<Space.Compact block>
|
||||||
|
<InputAddon>ALPN</InputAddon>
|
||||||
|
<Input placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||||
value={record.alpn} onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })} />
|
value={record.alpn} onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })} />
|
||||||
|
</Space.Compact>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={7}>
|
<Col xs={24} md={7}>
|
||||||
<Input addonBefore="Path" placeholder="/" value={record.path}
|
<Space.Compact block>
|
||||||
|
<InputAddon>Path</InputAddon>
|
||||||
|
<Input placeholder="/" value={record.path}
|
||||||
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })} />
|
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })} />
|
||||||
|
</Space.Compact>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={4}>
|
<Col xs={24} md={4}>
|
||||||
<InputNumber addonBefore="xver" min={0} max={2} style={{ width: '100%' }}
|
<Space.Compact block>
|
||||||
|
<InputAddon>xver</InputAddon>
|
||||||
|
<InputNumber min={0} max={2} style={{ width: '100%' }}
|
||||||
value={record.xver}
|
value={record.xver}
|
||||||
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })} />
|
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })} />
|
||||||
|
</Space.Compact>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1146,10 +1159,10 @@ export default function InboundFormModal({
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(ib.settings.accounts || []).map((account: any, idx: number) => (
|
{(ib.settings.accounts || []).map((account: any, idx: number) => (
|
||||||
<Space.Compact key={idx} className="mb-8" block>
|
<Space.Compact key={idx} className="mb-8" block>
|
||||||
<Input style={{ width: '45%' }} value={account.user}
|
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||||
addonBefore={String(idx + 1)} placeholder="Username"
|
<Input value={account.user} placeholder="Username"
|
||||||
onChange={(e) => { account.user = e.target.value; refresh(); }} />
|
onChange={(e) => { account.user = e.target.value; refresh(); }} />
|
||||||
<Input style={{ width: '45%' }} value={account.pass} placeholder="Password"
|
<Input value={account.pass} placeholder="Password"
|
||||||
onChange={(e) => { account.pass = e.target.value; refresh(); }} />
|
onChange={(e) => { account.pass = e.target.value; refresh(); }} />
|
||||||
<Button onClick={() => { ib.settings.delAccount(idx); refresh(); }}>
|
<Button onClick={() => { ib.settings.delAccount(idx); refresh(); }}>
|
||||||
<MinusOutlined />
|
<MinusOutlined />
|
||||||
|
|
@ -1208,9 +1221,10 @@ export default function InboundFormModal({
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(ib.settings.portMap as { name: string; value: string }[]).map((pm, idx) => (
|
{(ib.settings.portMap as { name: string; value: string }[]).map((pm, idx) => (
|
||||||
<Space.Compact key={`pm-${idx}`} className="mb-8" block>
|
<Space.Compact key={`pm-${idx}`} className="mb-8" block>
|
||||||
<Input style={{ width: '30%' }} value={pm.name} placeholder="5555" addonBefore={String(idx + 1)}
|
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||||
|
<Input value={pm.name} placeholder="5555"
|
||||||
onChange={(e) => { pm.name = e.target.value; refresh(); }} />
|
onChange={(e) => { pm.name = e.target.value; refresh(); }} />
|
||||||
<Input style={{ width: '60%' }} value={pm.value} placeholder="1.1.1.1:7777"
|
<Input value={pm.value} placeholder="1.1.1.1:7777"
|
||||||
onChange={(e) => { pm.value = e.target.value; refresh(); }} />
|
onChange={(e) => { pm.value = e.target.value; refresh(); }} />
|
||||||
<Button onClick={() => { ib.settings.removePortMap(idx); refresh(); }}>
|
<Button onClick={() => { ib.settings.removePortMap(idx); refresh(); }}>
|
||||||
<MinusOutlined />
|
<MinusOutlined />
|
||||||
|
|
@ -1240,11 +1254,15 @@ export default function InboundFormModal({
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
</Button>
|
</Button>
|
||||||
{(ib.settings.gateway || []).map((_ip: string, j: number) => (
|
{(ib.settings.gateway || []).map((_ip: string, j: number) => (
|
||||||
<Input key={`tun-gw-${j}`} className="mt-4"
|
<Space.Compact key={`tun-gw-${j}`} block className="mt-4">
|
||||||
|
<Input
|
||||||
placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'}
|
placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'}
|
||||||
value={ib.settings.gateway[j]}
|
value={ib.settings.gateway[j]}
|
||||||
onChange={(e) => { ib.settings.gateway[j] = e.target.value; refresh(); }}
|
onChange={(e) => { ib.settings.gateway[j] = e.target.value; refresh(); }} />
|
||||||
addonAfter={<Button size="small" onClick={() => { ib.settings.gateway.splice(j, 1); refresh(); }}><MinusOutlined /></Button>} />
|
<Button size="small" onClick={() => { ib.settings.gateway.splice(j, 1); refresh(); }}>
|
||||||
|
<MinusOutlined />
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="DNS">
|
<Form.Item label="DNS">
|
||||||
|
|
@ -1252,11 +1270,15 @@ export default function InboundFormModal({
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
</Button>
|
</Button>
|
||||||
{(ib.settings.dns || []).map((_ip: string, j: number) => (
|
{(ib.settings.dns || []).map((_ip: string, j: number) => (
|
||||||
<Input key={`tun-dns-${j}`} className="mt-4"
|
<Space.Compact key={`tun-dns-${j}`} block className="mt-4">
|
||||||
|
<Input
|
||||||
placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'}
|
placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'}
|
||||||
value={ib.settings.dns[j]}
|
value={ib.settings.dns[j]}
|
||||||
onChange={(e) => { ib.settings.dns[j] = e.target.value; refresh(); }}
|
onChange={(e) => { ib.settings.dns[j] = e.target.value; refresh(); }} />
|
||||||
addonAfter={<Button size="small" onClick={() => { ib.settings.dns.splice(j, 1); refresh(); }}><MinusOutlined /></Button>} />
|
<Button size="small" onClick={() => { ib.settings.dns.splice(j, 1); refresh(); }}>
|
||||||
|
<MinusOutlined />
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="User level">
|
<Form.Item label="User level">
|
||||||
|
|
@ -1268,11 +1290,15 @@ export default function InboundFormModal({
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
</Button>
|
</Button>
|
||||||
{(ib.settings.autoSystemRoutingTable || []).map((_ip: string, j: number) => (
|
{(ib.settings.autoSystemRoutingTable || []).map((_ip: string, j: number) => (
|
||||||
<Input key={`tun-rt-${j}`} className="mt-4"
|
<Space.Compact key={`tun-rt-${j}`} block className="mt-4">
|
||||||
|
<Input
|
||||||
placeholder={j === 0 ? '0.0.0.0/0' : '::/0'}
|
placeholder={j === 0 ? '0.0.0.0/0' : '::/0'}
|
||||||
value={ib.settings.autoSystemRoutingTable[j]}
|
value={ib.settings.autoSystemRoutingTable[j]}
|
||||||
onChange={(e) => { ib.settings.autoSystemRoutingTable[j] = e.target.value; refresh(); }}
|
onChange={(e) => { ib.settings.autoSystemRoutingTable[j] = e.target.value; refresh(); }} />
|
||||||
addonAfter={<Button size="small" onClick={() => { ib.settings.autoSystemRoutingTable.splice(j, 1); refresh(); }}><MinusOutlined /></Button>} />
|
<Button size="small" onClick={() => { ib.settings.autoSystemRoutingTable.splice(j, 1); refresh(); }}>
|
||||||
|
<MinusOutlined />
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={<Tooltip title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">Auto outbounds interface</Tooltip>}>
|
<Form.Item label={<Tooltip title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">Auto outbounds interface</Tooltip>}>
|
||||||
|
|
@ -1326,12 +1352,16 @@ export default function InboundFormModal({
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
</Button>
|
</Button>
|
||||||
{(peer.allowedIPs || []).map((_ip: string, j: number) => (
|
{(peer.allowedIPs || []).map((_ip: string, j: number) => (
|
||||||
<Input key={j} className="mt-4"
|
<Space.Compact key={j} block className="mt-4">
|
||||||
|
<Input
|
||||||
value={peer.allowedIPs[j]}
|
value={peer.allowedIPs[j]}
|
||||||
onChange={(e) => { peer.allowedIPs[j] = e.target.value; refresh(); }}
|
onChange={(e) => { peer.allowedIPs[j] = e.target.value; refresh(); }} />
|
||||||
addonAfter={peer.allowedIPs.length > 1
|
{peer.allowedIPs.length > 1 && (
|
||||||
? <Button size="small" onClick={() => { peer.allowedIPs.splice(j, 1); refresh(); }}><MinusOutlined /></Button>
|
<Button size="small" onClick={() => { peer.allowedIPs.splice(j, 1); refresh(); }}>
|
||||||
: undefined} />
|
<MinusOutlined />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Keep-alive">
|
<Form.Item label="Keep-alive">
|
||||||
|
|
@ -1388,12 +1418,16 @@ export default function InboundFormModal({
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={<>{t('pages.inbounds.stream.tcp.path')} <Button size="small" style={{ marginLeft: 6 }} onClick={() => { ib.stream.tcp.request.addPath('/'); refresh(); }}><PlusOutlined /></Button></>}>
|
<Form.Item label={<>{t('pages.inbounds.stream.tcp.path')} <Button size="small" style={{ marginLeft: 6 }} onClick={() => { ib.stream.tcp.request.addPath('/'); refresh(); }}><PlusOutlined /></Button></>}>
|
||||||
{(ib.stream.tcp.request.path || []).map((_p: string, idx: number) => (
|
{(ib.stream.tcp.request.path || []).map((_p: string, idx: number) => (
|
||||||
<Input key={`tcp-path-${idx}`} className="mb-4"
|
<Space.Compact key={`tcp-path-${idx}`} block className="mb-4">
|
||||||
|
<Input
|
||||||
value={ib.stream.tcp.request.path[idx]}
|
value={ib.stream.tcp.request.path[idx]}
|
||||||
onChange={(e) => { ib.stream.tcp.request.path[idx] = e.target.value; refresh(); }}
|
onChange={(e) => { ib.stream.tcp.request.path[idx] = e.target.value; refresh(); }} />
|
||||||
addonAfter={ib.stream.tcp.request.path.length > 1
|
{ib.stream.tcp.request.path.length > 1 && (
|
||||||
? <Button size="small" onClick={() => { ib.stream.tcp.request.removePath(idx); refresh(); }}><MinusOutlined /></Button>
|
<Button size="small" onClick={() => { ib.stream.tcp.request.removePath(idx); refresh(); }}>
|
||||||
: undefined} />
|
<MinusOutlined />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('pages.inbounds.stream.tcp.requestHeader')}>
|
<Form.Item label={t('pages.inbounds.stream.tcp.requestHeader')}>
|
||||||
|
|
@ -1405,10 +1439,11 @@ export default function InboundFormModal({
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(ib.stream.tcp.request.headers as { name: string; value: string }[]).map((h, idx) => (
|
{(ib.stream.tcp.request.headers as { name: string; value: string }[]).map((h, idx) => (
|
||||||
<Space.Compact key={`tcp-rh-${idx}`} className="mb-8" block>
|
<Space.Compact key={`tcp-rh-${idx}`} className="mb-8" block>
|
||||||
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)}
|
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||||
|
<Input value={h.name}
|
||||||
placeholder={t('pages.inbounds.stream.general.name')}
|
placeholder={t('pages.inbounds.stream.general.name')}
|
||||||
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
||||||
<Input style={{ width: '45%' }} value={h.value}
|
<Input value={h.value}
|
||||||
placeholder={t('pages.inbounds.stream.general.value')}
|
placeholder={t('pages.inbounds.stream.general.value')}
|
||||||
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
||||||
<Button onClick={() => { ib.stream.tcp.request.removeHeader(idx); refresh(); }}>
|
<Button onClick={() => { ib.stream.tcp.request.removeHeader(idx); refresh(); }}>
|
||||||
|
|
@ -1440,10 +1475,11 @@ export default function InboundFormModal({
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(ib.stream.tcp.response.headers as { name: string; value: string }[]).map((h, idx) => (
|
{(ib.stream.tcp.response.headers as { name: string; value: string }[]).map((h, idx) => (
|
||||||
<Space.Compact key={`tcp-rsh-${idx}`} className="mb-8" block>
|
<Space.Compact key={`tcp-rsh-${idx}`} className="mb-8" block>
|
||||||
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)}
|
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||||
|
<Input value={h.name}
|
||||||
placeholder={t('pages.inbounds.stream.general.name')}
|
placeholder={t('pages.inbounds.stream.general.name')}
|
||||||
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
||||||
<Input style={{ width: '45%' }} value={h.value}
|
<Input value={h.value}
|
||||||
placeholder={t('pages.inbounds.stream.general.value')}
|
placeholder={t('pages.inbounds.stream.general.value')}
|
||||||
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
||||||
<Button onClick={() => { ib.stream.tcp.response.removeHeader(idx); refresh(); }}>
|
<Button onClick={() => { ib.stream.tcp.response.removeHeader(idx); refresh(); }}>
|
||||||
|
|
@ -1482,10 +1518,11 @@ export default function InboundFormModal({
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(ib.stream.ws.headers as { name: string; value: string }[]).map((h, idx) => (
|
{(ib.stream.ws.headers as { name: string; value: string }[]).map((h, idx) => (
|
||||||
<Space.Compact key={`ws-h-${idx}`} className="mb-8" block>
|
<Space.Compact key={`ws-h-${idx}`} className="mb-8" block>
|
||||||
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)}
|
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||||
|
<Input value={h.name}
|
||||||
placeholder={t('pages.inbounds.stream.general.name')}
|
placeholder={t('pages.inbounds.stream.general.name')}
|
||||||
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
||||||
<Input style={{ width: '45%' }} value={h.value}
|
<Input value={h.value}
|
||||||
placeholder={t('pages.inbounds.stream.general.value')}
|
placeholder={t('pages.inbounds.stream.general.value')}
|
||||||
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
||||||
<Button onClick={() => { ib.stream.ws.removeHeader(idx); refresh(); }}>
|
<Button onClick={() => { ib.stream.ws.removeHeader(idx); refresh(); }}>
|
||||||
|
|
@ -1518,10 +1555,11 @@ export default function InboundFormModal({
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(ib.stream.httpupgrade.headers as { name: string; value: string }[]).map((h, idx) => (
|
{(ib.stream.httpupgrade.headers as { name: string; value: string }[]).map((h, idx) => (
|
||||||
<Space.Compact key={`hu-h-${idx}`} className="mb-8" block>
|
<Space.Compact key={`hu-h-${idx}`} className="mb-8" block>
|
||||||
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)}
|
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||||
|
<Input value={h.name}
|
||||||
placeholder={t('pages.inbounds.stream.general.name')}
|
placeholder={t('pages.inbounds.stream.general.name')}
|
||||||
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
||||||
<Input style={{ width: '45%' }} value={h.value}
|
<Input value={h.value}
|
||||||
placeholder={t('pages.inbounds.stream.general.value')}
|
placeholder={t('pages.inbounds.stream.general.value')}
|
||||||
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
||||||
<Button onClick={() => { ib.stream.httpupgrade.removeHeader(idx); refresh(); }}>
|
<Button onClick={() => { ib.stream.httpupgrade.removeHeader(idx); refresh(); }}>
|
||||||
|
|
@ -1545,10 +1583,11 @@ export default function InboundFormModal({
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(ib.stream.xhttp.headers as { name: string; value: string }[]).map((h, idx) => (
|
{(ib.stream.xhttp.headers as { name: string; value: string }[]).map((h, idx) => (
|
||||||
<Space.Compact key={`xh-h-${idx}`} className="mb-8" block>
|
<Space.Compact key={`xh-h-${idx}`} className="mb-8" block>
|
||||||
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)}
|
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||||
|
<Input value={h.name}
|
||||||
placeholder={t('pages.inbounds.stream.general.name')}
|
placeholder={t('pages.inbounds.stream.general.name')}
|
||||||
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
||||||
<Input style={{ width: '45%' }} value={h.value}
|
<Input value={h.value}
|
||||||
placeholder={t('pages.inbounds.stream.general.value')}
|
placeholder={t('pages.inbounds.stream.general.value')}
|
||||||
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
||||||
<Button onClick={() => { ib.stream.xhttp.removeHeader(idx); refresh(); }}>
|
<Button onClick={() => { ib.stream.xhttp.removeHeader(idx); refresh(); }}>
|
||||||
|
|
@ -1665,9 +1704,11 @@ export default function InboundFormModal({
|
||||||
<InputNumber value={row.port} style={{ width: '15%' }} min={1} max={65535}
|
<InputNumber value={row.port} style={{ width: '15%' }} min={1} max={65535}
|
||||||
onChange={(v) => { row.port = Number(v) || 0; refresh(); }} />
|
onChange={(v) => { row.port = Number(v) || 0; refresh(); }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Input style={{ width: '35%' }} value={row.remark} placeholder={t('pages.inbounds.remark')}
|
<Input style={{ width: '25%' }} value={row.remark} placeholder={t('pages.inbounds.remark')}
|
||||||
onChange={(e) => { row.remark = e.target.value; refresh(); }}
|
onChange={(e) => { row.remark = e.target.value; refresh(); }} />
|
||||||
addonAfter={<MinusOutlined onClick={() => { ib.stream.externalProxy.splice(idx, 1); refresh(); }} />} />
|
<InputAddon onClick={() => { ib.stream.externalProxy.splice(idx, 1); refresh(); }}>
|
||||||
|
<MinusOutlined />
|
||||||
|
</InputAddon>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
@ -1762,9 +1803,10 @@ export default function InboundFormModal({
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(ib.stream.hysteria.masquerade.headers as { name: string; value: string }[]).map((h, idx) => (
|
{(ib.stream.hysteria.masquerade.headers as { name: string; value: string }[]).map((h, idx) => (
|
||||||
<Space.Compact key={`mh-${idx}`} className="mb-8" block>
|
<Space.Compact key={`mh-${idx}`} className="mb-8" block>
|
||||||
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)} placeholder="Name"
|
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||||
|
<Input value={h.name} placeholder="Name"
|
||||||
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
onChange={(e) => { h.name = e.target.value; refresh(); }} />
|
||||||
<Input style={{ width: '45%' }} value={h.value} placeholder="Value"
|
<Input value={h.value} placeholder="Value"
|
||||||
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
onChange={(e) => { h.value = e.target.value; refresh(); }} />
|
||||||
<Button onClick={() => { ib.stream.hysteria.masquerade.removeHeader(idx); refresh(); }}>
|
<Button onClick={() => { ib.stream.hysteria.masquerade.removeHeader(idx); refresh(); }}>
|
||||||
<MinusOutlined />
|
<MinusOutlined />
|
||||||
|
|
@ -2078,6 +2120,8 @@ export default function InboundFormModal({
|
||||||
tabItems.push({ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: renderAdvancedTab() });
|
tabItems.push({ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: renderAdvancedTab() });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={title}
|
title={title}
|
||||||
|
|
@ -2092,5 +2136,6 @@ export default function InboundFormModal({
|
||||||
>
|
>
|
||||||
<Tabs activeKey={activeTabKey} onChange={handleTabChange} items={tabItems} />
|
<Tabs activeKey={activeTabKey} onChange={handleTabChange} items={tabItems} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip, message } from 'antd';
|
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
|
||||||
|
import { getMessage } from '@/utils/messageBus';
|
||||||
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
|
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -106,7 +107,7 @@ interface InboundInfoModalProps {
|
||||||
|
|
||||||
function copyText(value: unknown, t: (k: string) => string) {
|
function copyText(value: unknown, t: (k: string) => string) {
|
||||||
ClipboardManager.copyText(String(value ?? '')).then((ok) => {
|
ClipboardManager.copyText(String(value ?? '')).then((ok) => {
|
||||||
if (ok) message.success(t('copied'));
|
if (ok) getMessage().success(t('copied'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
Spin,
|
Spin,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import {
|
import {
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
PieChartOutlined,
|
PieChartOutlined,
|
||||||
|
|
@ -76,6 +78,10 @@ export default function InboundsPage() {
|
||||||
applyInboundsEvent,
|
applyInboundsEvent,
|
||||||
} = useInbounds();
|
} = useInbounds();
|
||||||
|
|
||||||
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
||||||
const { nodes: nodesList } = useNodes();
|
const { nodes: nodesList } = useNodes();
|
||||||
const nodesById = useMemo(() => {
|
const nodesById = useMemo(() => {
|
||||||
const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>();
|
const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>();
|
||||||
|
|
@ -305,7 +311,7 @@ export default function InboundsPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const confirmDelete = useCallback((dbInbound: any) => {
|
const confirmDelete = useCallback((dbInbound: any) => {
|
||||||
Modal.confirm({
|
modal.confirm({
|
||||||
title: `Delete inbound "${dbInbound.remark}"?`,
|
title: `Delete inbound "${dbInbound.remark}"?`,
|
||||||
content: 'This removes the inbound and all its clients. This cannot be undone.',
|
content: 'This removes the inbound and all its clients. This cannot be undone.',
|
||||||
okText: 'Delete',
|
okText: 'Delete',
|
||||||
|
|
@ -316,10 +322,10 @@ export default function InboundsPage() {
|
||||||
if (msg?.success) await refresh();
|
if (msg?.success) await refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [refresh]);
|
}, [modal, refresh]);
|
||||||
|
|
||||||
const confirmResetTraffic = useCallback((dbInbound: any) => {
|
const confirmResetTraffic = useCallback((dbInbound: any) => {
|
||||||
Modal.confirm({
|
modal.confirm({
|
||||||
title: `Reset traffic for "${dbInbound.remark}"?`,
|
title: `Reset traffic for "${dbInbound.remark}"?`,
|
||||||
content: 'Resets up/down counters to 0 for this inbound.',
|
content: 'Resets up/down counters to 0 for this inbound.',
|
||||||
okText: 'Reset',
|
okText: 'Reset',
|
||||||
|
|
@ -329,10 +335,10 @@ export default function InboundsPage() {
|
||||||
if (msg?.success) await refresh();
|
if (msg?.success) await refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [refresh]);
|
}, [modal, refresh]);
|
||||||
|
|
||||||
const confirmClone = useCallback((dbInbound: any) => {
|
const confirmClone = useCallback((dbInbound: any) => {
|
||||||
Modal.confirm({
|
modal.confirm({
|
||||||
title: `Clone inbound "${dbInbound.remark}"?`,
|
title: `Clone inbound "${dbInbound.remark}"?`,
|
||||||
content: 'Creates a copy with a new port and an empty client list.',
|
content: 'Creates a copy with a new port and an empty client list.',
|
||||||
okText: 'Clone',
|
okText: 'Clone',
|
||||||
|
|
@ -365,7 +371,7 @@ export default function InboundsPage() {
|
||||||
if (msg?.success) await refresh();
|
if (msg?.success) await refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [refresh]);
|
}, [modal, refresh]);
|
||||||
|
|
||||||
const onGeneralAction = useCallback((key: GeneralAction) => {
|
const onGeneralAction = useCallback((key: GeneralAction) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
|
|
@ -373,7 +379,7 @@ export default function InboundsPage() {
|
||||||
case 'export': exportAllLinks(); break;
|
case 'export': exportAllLinks(); break;
|
||||||
case 'subs': exportAllSubs(); break;
|
case 'subs': exportAllSubs(); break;
|
||||||
case 'resetInbounds':
|
case 'resetInbounds':
|
||||||
Modal.confirm({
|
modal.confirm({
|
||||||
title: 'Reset all inbound traffic?',
|
title: 'Reset all inbound traffic?',
|
||||||
okText: 'Reset',
|
okText: 'Reset',
|
||||||
cancelText: 'Cancel',
|
cancelText: 'Cancel',
|
||||||
|
|
@ -384,9 +390,9 @@ export default function InboundsPage() {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
|
||||||
}
|
}
|
||||||
}, [importInbound, exportAllLinks, exportAllSubs, refresh]);
|
}, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
|
||||||
|
|
||||||
const onRowAction = useCallback(({ key, dbInbound }: { key: RowAction; dbInbound: any }) => {
|
const onRowAction = useCallback(({ key, dbInbound }: { key: RowAction; dbInbound: any }) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
|
|
@ -421,15 +427,17 @@ export default function InboundsPage() {
|
||||||
confirmClone(dbInbound);
|
confirmClone(dbInbound);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
message.info(`Action "${key}" — coming in a later 5f subphase`);
|
messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
|
||||||
}
|
}
|
||||||
}, [openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone]);
|
}, [openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]);
|
||||||
|
|
||||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||||
const requestUri = typeof window !== 'undefined' ? window.location.pathname : '';
|
const requestUri = typeof window !== 'undefined' ? window.location.pathname : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
|
{modalContextHolder}
|
||||||
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
|
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
|
||||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Collapse, Modal } from 'antd';
|
import { Collapse, Modal } from 'antd';
|
||||||
|
import type { CollapseProps } from 'antd';
|
||||||
|
|
||||||
import { Protocols } from '@/models/inbound.js';
|
import { Protocols } from '@/models/inbound.js';
|
||||||
import QrPanel from './QrPanel';
|
import QrPanel from './QrPanel';
|
||||||
|
|
@ -56,7 +57,7 @@ export default function QrCodeModal({
|
||||||
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
||||||
const [subLink, setSubLink] = useState('');
|
const [subLink, setSubLink] = useState('');
|
||||||
const [subJsonLink, setSubJsonLink] = useState('');
|
const [subJsonLink, setSubJsonLink] = useState('');
|
||||||
const [activeKeys, setActiveKeys] = useState<string[]>([]);
|
const [defaultActive, setDefaultActive] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !dbInbound) return;
|
if (!open || !dbInbound) return;
|
||||||
|
|
@ -83,7 +84,7 @@ export default function QrCodeModal({
|
||||||
}
|
}
|
||||||
setSubLink(nextSub);
|
setSubLink(nextSub);
|
||||||
setSubJsonLink(nextSubJson);
|
setSubJsonLink(nextSubJson);
|
||||||
setActiveKeys(nextSub ? ['sub'] : []);
|
setDefaultActive(nextSub ? ['sub'] : []);
|
||||||
}, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]);
|
}, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]);
|
||||||
|
|
||||||
const qrItems = useMemo<QrItem[]>(() => {
|
const qrItems = useMemo<QrItem[]>(() => {
|
||||||
|
|
@ -111,7 +112,8 @@ export default function QrCodeModal({
|
||||||
return items;
|
return items;
|
||||||
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
|
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
|
||||||
|
|
||||||
const collapseItems = qrItems.map((item) => ({
|
const collapseItems: CollapseProps['items'] = useMemo(
|
||||||
|
() => qrItems.map((item) => ({
|
||||||
key: item.key,
|
key: item.key,
|
||||||
label: item.header,
|
label: item.header,
|
||||||
children: (
|
children: (
|
||||||
|
|
@ -122,15 +124,17 @@ export default function QrCodeModal({
|
||||||
showQr={!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')}
|
showQr={!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}));
|
})),
|
||||||
|
[qrItems],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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 && (
|
{dbInbound && collapseItems && collapseItems.length > 0 && (
|
||||||
<Collapse
|
<Collapse
|
||||||
|
key={collapseItems.map((i) => i?.key).join('|')}
|
||||||
ghost
|
ghost
|
||||||
activeKey={activeKeys}
|
defaultActiveKey={defaultActive}
|
||||||
onChange={(keys) => setActiveKeys(Array.isArray(keys) ? keys : [keys])}
|
|
||||||
items={collapseItems}
|
items={collapseItems}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,12 @@ export default function QrPanel({
|
||||||
showQr = true,
|
showQr = true,
|
||||||
}: QrPanelProps) {
|
}: QrPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const qrRef = useRef<HTMLDivElement | null>(null);
|
const qrRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
const ok = await ClipboardManager.copyText(value);
|
const ok = await ClipboardManager.copyText(value);
|
||||||
if (ok) message.success(t('copied'));
|
if (ok) messageApi.success(t('copied'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function download() {
|
function download() {
|
||||||
|
|
@ -77,7 +78,7 @@ export default function QrPanel({
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||||
message.success(t('copied'));
|
messageApi.success(t('copied'));
|
||||||
} catch {
|
} catch {
|
||||||
downloadImageBlob(blob, remark);
|
downloadImageBlob(blob, remark);
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +92,7 @@ export default function QrPanel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="qr-panel">
|
<div className="qr-panel">
|
||||||
|
{messageContextHolder}
|
||||||
<div className="qr-panel-header">
|
<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')}>
|
<Tooltip title={t('copy')}>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,57 @@
|
||||||
.backup-list {
|
.backup-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .backup-list,
|
||||||
|
html[data-theme='ultra-dark'] .backup-list {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-item {
|
.backup-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .backup-item,
|
||||||
|
html[data-theme='ultra-dark'] .backup-item {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-meta {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
line-height: 1.5715;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .backup-title,
|
||||||
|
html[data-theme='ultra-dark'] .backup-title {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .backup-description,
|
||||||
|
html[data-theme='ultra-dark'] .backup-description {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, List, Modal } from 'antd';
|
import { Button, Modal } from 'antd';
|
||||||
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||||
|
|
@ -65,23 +65,23 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
||||||
footer={null}
|
footer={null}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
>
|
>
|
||||||
<List bordered className="backup-list">
|
<div className="backup-list">
|
||||||
<List.Item className="backup-item">
|
<div className="backup-item">
|
||||||
<List.Item.Meta
|
<div className="backup-meta">
|
||||||
title={t('pages.index.exportDatabase')}
|
<div className="backup-title">{t('pages.index.exportDatabase')}</div>
|
||||||
description={t('pages.index.exportDatabaseDesc')}
|
<div className="backup-description">{t('pages.index.exportDatabaseDesc')}</div>
|
||||||
/>
|
</div>
|
||||||
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
|
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
|
||||||
</List.Item>
|
</div>
|
||||||
|
|
||||||
<List.Item className="backup-item">
|
<div className="backup-item">
|
||||||
<List.Item.Meta
|
<div className="backup-meta">
|
||||||
title={t('pages.index.importDatabase')}
|
<div className="backup-title">{t('pages.index.importDatabase')}</div>
|
||||||
description={t('pages.index.importDatabaseDesc')}
|
<div className="backup-description">{t('pages.index.importDatabaseDesc')}</div>
|
||||||
/>
|
</div>
|
||||||
<Button type="primary" onClick={importDb} icon={<UploadOutlined />} />
|
<Button type="primary" onClick={importDb} icon={<UploadOutlined />} />
|
||||||
</List.Item>
|
</div>
|
||||||
</List>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export default function CustomGeoFormModal({
|
||||||
onSaved,
|
onSaved,
|
||||||
}: CustomGeoFormModalProps) {
|
}: CustomGeoFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [type, setType] = useState<'geosite' | 'geoip'>('geosite');
|
const [type, setType] = useState<'geosite' | 'geoip'>('geosite');
|
||||||
const [alias, setAlias] = useState('');
|
const [alias, setAlias] = useState('');
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
|
|
@ -47,22 +48,22 @@ export default function CustomGeoFormModal({
|
||||||
|
|
||||||
function validate(): boolean {
|
function validate(): boolean {
|
||||||
if (!/^[a-z0-9_-]+$/.test(alias || '')) {
|
if (!/^[a-z0-9_-]+$/.test(alias || '')) {
|
||||||
message.error(t('pages.index.customGeoValidationAlias'));
|
messageApi.error(t('pages.index.customGeoValidationAlias'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const u = (url || '').trim();
|
const u = (url || '').trim();
|
||||||
if (!/^https?:\/\//i.test(u)) {
|
if (!/^https?:\/\//i.test(u)) {
|
||||||
message.error(t('pages.index.customGeoValidationUrl'));
|
messageApi.error(t('pages.index.customGeoValidationUrl'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(u);
|
const parsed = new URL(u);
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
message.error(t('pages.index.customGeoValidationUrl'));
|
messageApi.error(t('pages.index.customGeoValidationUrl'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error(t('pages.index.customGeoValidationUrl'));
|
messageApi.error(t('pages.index.customGeoValidationUrl'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -86,6 +87,8 @@ export default function CustomGeoFormModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')}
|
title={editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')}
|
||||||
|
|
@ -124,5 +127,6 @@ export default function CustomGeoFormModal({
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ function extDisplay(record: CustomGeoListRecord): string {
|
||||||
export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
|
export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [list, setList] = useState<CustomGeoListRecord[]>([]);
|
const [list, setList] = useState<CustomGeoListRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [updatingAll, setUpdatingAll] = useState(false);
|
const [updatingAll, setUpdatingAll] = useState(false);
|
||||||
|
|
@ -85,7 +86,7 @@ export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
|
||||||
async function copyExt(record: CustomGeoListRecord) {
|
async function copyExt(record: CustomGeoListRecord) {
|
||||||
const text = extDisplay(record);
|
const text = extDisplay(record);
|
||||||
const ok = await ClipboardManager.copyText(text);
|
const ok = await ClipboardManager.copyText(text);
|
||||||
if (ok) message.success(`${t('copied')}: ${text}`);
|
if (ok) messageApi.success(`${t('copied')}: ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(record: CustomGeoListRecord) {
|
function confirmDelete(record: CustomGeoListRecord) {
|
||||||
|
|
@ -120,7 +121,7 @@ export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
|
||||||
const failed = msg?.obj?.failed?.length || 0;
|
const failed = msg?.obj?.failed?.length || 0;
|
||||||
if (msg?.success || ok > 0) {
|
if (msg?.success || ok > 0) {
|
||||||
await loadList();
|
await loadList();
|
||||||
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
|
if (failed > 0) messageApi.warning(`Updated ${ok}, failed ${failed}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingAll(false);
|
setUpdatingAll(false);
|
||||||
|
|
@ -229,6 +230,7 @@ export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="custom-geo-section">
|
<div className="custom-geo-section">
|
||||||
|
{messageContextHolder}
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import AppSidebar from '@/components/AppSidebar';
|
import AppSidebar from '@/components/AppSidebar';
|
||||||
import CustomStatistic from '@/components/CustomStatistic';
|
import CustomStatistic from '@/components/CustomStatistic';
|
||||||
import JsonEditor from '@/components/JsonEditor';
|
import JsonEditor from '@/components/JsonEditor';
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import StatusCard from './StatusCard';
|
import StatusCard from './StatusCard';
|
||||||
import XrayStatusCard from './XrayStatusCard';
|
import XrayStatusCard from './XrayStatusCard';
|
||||||
import PanelUpdateModal from './PanelUpdateModal';
|
import PanelUpdateModal from './PanelUpdateModal';
|
||||||
|
|
@ -57,6 +58,8 @@ export default function IndexPage() {
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { status, fetched, refresh } = useStatus();
|
const { status, fetched, refresh } = useStatus();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
||||||
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
||||||
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
||||||
|
|
@ -139,7 +142,7 @@ export default function IndexPage() {
|
||||||
|
|
||||||
async function copyConfig() {
|
async function copyConfig() {
|
||||||
const ok = await ClipboardManager.copyText(configText || '');
|
const ok = await ClipboardManager.copyText(configText || '');
|
||||||
if (ok) message.success('Copied');
|
if (ok) messageApi.success('Copied');
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadConfig() {
|
function downloadConfig() {
|
||||||
|
|
@ -150,6 +153,7 @@ export default function IndexPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass}>
|
||||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,31 @@
|
||||||
|
|
||||||
.version-list {
|
.version-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .version-list,
|
||||||
|
html[data-theme='ultra-dark'] .version-list {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-list-item {
|
.version-list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .version-list-item,
|
||||||
|
html[data-theme='ultra-dark'] .version-list-item {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-row {
|
.actions-row {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Button, List, Modal, Tag } from 'antd';
|
import { Alert, Button, Modal, Tag } from 'antd';
|
||||||
import { CloudDownloadOutlined } from '@ant-design/icons';
|
import { CloudDownloadOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|
@ -84,23 +84,23 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<List bordered className="version-list">
|
<div className="version-list">
|
||||||
<List.Item className="version-list-item">
|
<div className="version-list-item">
|
||||||
<span>{t('pages.index.currentPanelVersion')}</span>
|
<span>{t('pages.index.currentPanelVersion')}</span>
|
||||||
<Tag color="green">v{info.currentVersion || '?'}</Tag>
|
<Tag color="green">v{info.currentVersion || '?'}</Tag>
|
||||||
</List.Item>
|
</div>
|
||||||
{info.updateAvailable ? (
|
{info.updateAvailable ? (
|
||||||
<List.Item className="version-list-item">
|
<div className="version-list-item">
|
||||||
<span>{t('pages.index.latestPanelVersion')}</span>
|
<span>{t('pages.index.latestPanelVersion')}</span>
|
||||||
<Tag color="purple">{info.latestVersion || '-'}</Tag>
|
<Tag color="purple">{info.latestVersion || '-'}</Tag>
|
||||||
</List.Item>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<List.Item className="version-list-item">
|
<div className="version-list-item">
|
||||||
<span>{t('pages.index.panelUpToDate')}</span>
|
<span>{t('pages.index.panelUpToDate')}</span>
|
||||||
<Tag color="green">{t('pages.index.panelUpToDate')}</Tag>
|
<Tag color="green">{t('pages.index.panelUpToDate')}</Tag>
|
||||||
</List.Item>
|
</div>
|
||||||
)}
|
)}
|
||||||
</List>
|
</div>
|
||||||
|
|
||||||
<div className="actions-row">
|
<div className="actions-row">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Modal, Select, Tabs } from 'antd';
|
import { Modal, Select, Tabs } from 'antd';
|
||||||
|
|
||||||
|
|
@ -54,7 +54,6 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
|
||||||
const [bucket, setBucket] = useState(2);
|
const [bucket, setBucket] = useState(2);
|
||||||
const [points, setPoints] = useState<number[]>([]);
|
const [points, setPoints] = useState<number[]>([]);
|
||||||
const [labels, setLabels] = useState<string[]>([]);
|
const [labels, setLabels] = useState<string[]>([]);
|
||||||
const openRef = useRef(open);
|
|
||||||
|
|
||||||
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
|
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
|
||||||
const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
|
const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
|
||||||
|
|
@ -93,15 +92,14 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
|
||||||
}, [activeMetric, bucket]);
|
}, [activeMetric, bucket]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
openRef.current = open;
|
|
||||||
if (open) {
|
if (open) {
|
||||||
setActiveKey('cpu');
|
setActiveKey('cpu');
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openRef.current) fetchBucket();
|
if (open) fetchBucket();
|
||||||
}, [activeKey, bucket, fetchBucket]);
|
}, [open, activeKey, bucket, fetchBucket]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,31 @@
|
||||||
|
|
||||||
.version-list {
|
.version-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .version-list,
|
||||||
|
html[data-theme='ultra-dark'] .version-list {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-list-item {
|
.version-list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .version-list-item,
|
||||||
|
html[data-theme='ultra-dark'] .version-list-item {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reload-icon {
|
.reload-icon {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Button, Collapse, List, Modal, Radio, Spin, Tag, Tooltip } from 'antd';
|
import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd';
|
||||||
import { ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
@ -119,17 +119,17 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
|
||||||
title={t('pages.index.xraySwitchClickDesk')}
|
title={t('pages.index.xraySwitchClickDesk')}
|
||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
<List bordered className="version-list">
|
<div className="version-list">
|
||||||
{versions.map((version, index) => (
|
{versions.map((version, index) => (
|
||||||
<List.Item key={version} className="version-list-item">
|
<div key={version} className="version-list-item">
|
||||||
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{version}</Tag>
|
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{version}</Tag>
|
||||||
<Radio
|
<Radio
|
||||||
checked={version === `v${status?.xray?.version}`}
|
checked={version === `v${status?.xray?.version}`}
|
||||||
onClick={() => switchXrayVersion(version)}
|
onClick={() => switchXrayVersion(version)}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</div>
|
||||||
))}
|
))}
|
||||||
</List>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -138,9 +138,9 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
|
||||||
label: 'Geofiles',
|
label: 'Geofiles',
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<List bordered className="version-list">
|
<div className="version-list">
|
||||||
{GEOFILES.map((file, index) => (
|
{GEOFILES.map((file, index) => (
|
||||||
<List.Item key={file} className="version-list-item">
|
<div key={file} className="version-list-item">
|
||||||
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{file}</Tag>
|
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{file}</Tag>
|
||||||
<Tooltip title={t('update')}>
|
<Tooltip title={t('update')}>
|
||||||
<ReloadOutlined
|
<ReloadOutlined
|
||||||
|
|
@ -148,9 +148,9 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
|
||||||
onClick={() => updateGeofile(file)}
|
onClick={() => updateGeofile(file)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</List.Item>
|
</div>
|
||||||
))}
|
))}
|
||||||
</List>
|
</div>
|
||||||
<div className="actions-row">
|
<div className="actions-row">
|
||||||
<Button onClick={() => updateGeofile('')}>
|
<Button onClick={() => updateGeofile('')}>
|
||||||
{t('pages.index.geofilesUpdateAll')}
|
{t('pages.index.geofilesUpdateAll')}
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
|
||||||
}, [open, fetchState, stopObsPolling]);
|
}, [open, fetchState, stopObsPolling]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openRef.current) return;
|
if (!open) return;
|
||||||
if (isObservatory) {
|
if (isObservatory) {
|
||||||
fetchObservatory();
|
fetchObservatory();
|
||||||
fetchObsBucket();
|
fetchObsBucket();
|
||||||
|
|
@ -202,20 +202,20 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
|
||||||
return () => {
|
return () => {
|
||||||
stopObsPolling();
|
stopObsPolling();
|
||||||
};
|
};
|
||||||
}, [activeKey, isObservatory, fetchObservatory, fetchObsBucket, fetchMetricBucket, stopObsPolling]);
|
}, [open, activeKey, isObservatory, fetchObservatory, fetchObsBucket, fetchMetricBucket, stopObsPolling]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openRef.current) return;
|
if (!open) return;
|
||||||
if (isObservatory) {
|
if (isObservatory) {
|
||||||
fetchObsBucket();
|
fetchObsBucket();
|
||||||
} else {
|
} else {
|
||||||
fetchMetricBucket();
|
fetchMetricBucket();
|
||||||
}
|
}
|
||||||
}, [bucket, isObservatory, fetchObsBucket, fetchMetricBucket]);
|
}, [open, bucket, isObservatory, fetchObsBucket, fetchMetricBucket]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openRef.current && isObservatory) fetchObsBucket();
|
if (open && isObservatory) fetchObsBucket();
|
||||||
}, [obsActiveTag, isObservatory, fetchObsBucket]);
|
}, [open, obsActiveTag, isObservatory, fetchObsBucket]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil, LanguageManager } from '@/utils';
|
import { HttpUtil, LanguageManager } from '@/utils';
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
|
|
||||||
|
|
@ -35,6 +37,11 @@ const basePath = window.X_UI_BASE_PATH || '';
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMessageInstance(messageApi);
|
||||||
|
}, [messageApi]);
|
||||||
|
|
||||||
const [fetched, setFetched] = useState(false);
|
const [fetched, setFetched] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
@ -131,6 +138,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass}>
|
||||||
<Layout.Content className="login-content">
|
<Layout.Content className="login-content">
|
||||||
<div className="login-toolbar">
|
<div className="login-toolbar">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Button,
|
||||||
Col,
|
Col,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
|
@ -74,6 +75,7 @@ export default function NodeFormModal({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NodeFormModalProps) {
|
}: NodeFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(defaultForm);
|
const [form, setForm] = useState<FormState>(defaultForm);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
@ -132,7 +134,7 @@ export default function NodeFormModal({
|
||||||
try {
|
try {
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
if (!payload.address || !payload.port) {
|
if (!payload.address || !payload.port) {
|
||||||
message.error(t('pages.nodes.toasts.fillRequired'));
|
messageApi.error(t('pages.nodes.toasts.fillRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg = await testConnection(payload);
|
const msg = await testConnection(payload);
|
||||||
|
|
@ -149,7 +151,7 @@ export default function NodeFormModal({
|
||||||
async function onSave() {
|
async function onSave() {
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
if (!payload.name || !payload.address || !payload.port) {
|
if (!payload.name || !payload.address || !payload.port) {
|
||||||
message.error(t('pages.nodes.toasts.fillRequired'));
|
messageApi.error(t('pages.nodes.toasts.fillRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
@ -168,6 +170,8 @@ export default function NodeFormModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={title}
|
title={title}
|
||||||
|
|
@ -267,9 +271,9 @@ export default function NodeFormModal({
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<div className="test-row">
|
<div className="test-row">
|
||||||
<button type="button" disabled={testing} className="ant-btn ant-btn-default" onClick={onTest}>
|
<Button type="default" loading={testing} onClick={onTest}>
|
||||||
{t('pages.nodes.testConnection')}
|
{t('pages.nodes.testConnection')}
|
||||||
</button>
|
</Button>
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div className="test-result">
|
<div className="test-result">
|
||||||
{testResult.status === 'online' ? (
|
{testResult.status === 'online' ? (
|
||||||
|
|
@ -292,5 +296,6 @@ export default function NodeFormModal({
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,37 @@ export default function NodeList({
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<ColumnsType<NodeRow>>(() => [
|
const columns = useMemo<ColumnsType<NodeRow>>(() => [
|
||||||
|
{
|
||||||
|
title: t('pages.nodes.actions'),
|
||||||
|
align: 'center',
|
||||||
|
width: 160,
|
||||||
|
render: (_value, record) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title={t('pages.nodes.probe')}>
|
||||||
|
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('edit')}>
|
||||||
|
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('delete')}>
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.nodes.enable'),
|
||||||
|
dataIndex: 'enable',
|
||||||
|
align: 'center',
|
||||||
|
width: 80,
|
||||||
|
render: (_value, record) => (
|
||||||
|
<Switch
|
||||||
|
checked={!!record.enable}
|
||||||
|
size="small"
|
||||||
|
onChange={(v) => onToggleEnable(record, v)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('pages.nodes.name'),
|
title: t('pages.nodes.name'),
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
|
|
@ -234,38 +265,6 @@ export default function NodeList({
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('pages.nodes.enable'),
|
|
||||||
dataIndex: 'enable',
|
|
||||||
align: 'center',
|
|
||||||
width: 80,
|
|
||||||
render: (_value, record) => (
|
|
||||||
<Switch
|
|
||||||
checked={!!record.enable}
|
|
||||||
size="small"
|
|
||||||
onChange={(v) => onToggleEnable(record, v)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.nodes.actions'),
|
|
||||||
align: 'center',
|
|
||||||
width: 160,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_value, record) => (
|
|
||||||
<Space>
|
|
||||||
<Tooltip title={t('pages.nodes.probe')}>
|
|
||||||
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('edit')}>
|
|
||||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('delete')}>
|
|
||||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]);
|
], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd';
|
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,6 +17,7 @@ import AppSidebar from '@/components/AppSidebar';
|
||||||
import CustomStatistic from '@/components/CustomStatistic';
|
import CustomStatistic from '@/components/CustomStatistic';
|
||||||
import NodeList from './NodeList';
|
import NodeList from './NodeList';
|
||||||
import NodeFormModal from './NodeFormModal';
|
import NodeFormModal from './NodeFormModal';
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import './NodesPage.css';
|
import './NodesPage.css';
|
||||||
|
|
||||||
const basePath = window.X_UI_BASE_PATH || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
|
@ -27,6 +28,8 @@ export default function NodesPage() {
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -76,21 +79,21 @@ export default function NodesPage() {
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const msg = await remove(node.id);
|
const msg = await remove(node.id);
|
||||||
if (msg?.success) message.success(t('pages.nodes.toasts.deleted'));
|
if (msg?.success) messageApi.success(t('pages.nodes.toasts.deleted'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [modal, t, remove]);
|
}, [modal, t, remove, messageApi]);
|
||||||
|
|
||||||
const onProbe = useCallback(async (node: NodeRecord) => {
|
const onProbe = useCallback(async (node: NodeRecord) => {
|
||||||
const msg = await probe(node.id);
|
const msg = await probe(node.id);
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
if (msg.obj.status === 'online') {
|
if (msg.obj.status === 'online') {
|
||||||
message.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
|
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
|
||||||
} else {
|
} else {
|
||||||
message.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
|
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [probe, t]);
|
}, [probe, t, messageApi]);
|
||||||
|
|
||||||
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
|
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
|
||||||
await setEnable(node.id, next);
|
await setEnable(node.id, next);
|
||||||
|
|
@ -105,6 +108,7 @@ export default function NodesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass}>
|
||||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||||
|
|
|
||||||
|
|
@ -82,3 +82,9 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.security-actions {
|
||||||
|
padding: 12px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
Empty,
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
List,
|
|
||||||
Modal,
|
Modal,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
|
@ -61,6 +60,7 @@ const TFA_INITIAL: TfaState = {
|
||||||
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
|
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL);
|
const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL);
|
||||||
const [user, setUser] = useState({
|
const [user, setUser] = useState({
|
||||||
|
|
@ -145,7 +145,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(token);
|
await navigator.clipboard.writeText(token);
|
||||||
message.success(t('copySuccess'));
|
messageApi.success(t('copySuccess'));
|
||||||
} catch {
|
} catch {
|
||||||
const ta = document.createElement('textarea');
|
const ta = document.createElement('textarea');
|
||||||
ta.value = token;
|
ta.value = token;
|
||||||
|
|
@ -153,7 +153,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
ta.select();
|
ta.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
document.body.removeChild(ta);
|
document.body.removeChild(ta);
|
||||||
message.success(t('copySuccess'));
|
messageApi.success(t('copySuccess'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +165,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
async function confirmCreateToken() {
|
async function confirmCreateToken() {
|
||||||
const name = createName.trim();
|
const name = createName.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
|
messageApi.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
|
|
@ -231,7 +231,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
type: 'set',
|
type: 'set',
|
||||||
onConfirm: (ok: boolean) => {
|
onConfirm: (ok: boolean) => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
message.success(t('pages.settings.security.twoFactorModalSetSuccess'));
|
messageApi.success(t('pages.settings.security.twoFactorModalSetSuccess'));
|
||||||
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
|
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
|
||||||
} else {
|
} else {
|
||||||
updateSetting({ twoFactorEnable: false });
|
updateSetting({ twoFactorEnable: false });
|
||||||
|
|
@ -246,7 +246,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
onConfirm: (ok: boolean) => {
|
onConfirm: (ok: boolean) => {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
message.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
|
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
|
||||||
updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
|
updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -255,6 +255,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
<Collapse defaultActiveKey="1" items={[
|
<Collapse defaultActiveKey="1" items={[
|
||||||
{
|
{
|
||||||
|
|
@ -278,13 +279,13 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
<Input.Password value={user.newPassword} autoComplete="new-password"
|
<Input.Password value={user.newPassword} autoComplete="new-password"
|
||||||
onChange={(e) => updateUserField('newPassword', e.target.value)} />
|
onChange={(e) => updateUserField('newPassword', e.target.value)} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<List.Item>
|
<div className="security-actions">
|
||||||
<Space style={{ padding: '0 20px' }}>
|
<Space style={{ padding: '0 20px' }}>
|
||||||
<Button type="primary" loading={updating} onClick={onUpdateUserClick}>
|
<Button type="primary" loading={updating} onClick={onUpdateUserClick}>
|
||||||
{t('confirm')}
|
{t('confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</List.Item>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
Spin,
|
Spin,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
|
|
@ -24,6 +25,7 @@ import {
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useAllSetting } from '@/hooks/useAllSetting';
|
import { useAllSetting } from '@/hooks/useAllSetting';
|
||||||
|
|
@ -77,6 +79,11 @@ export default function SettingsPage() {
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMessageInstance(messageApi);
|
||||||
|
}, [messageApi]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
allSetting,
|
allSetting,
|
||||||
|
|
@ -259,6 +266,7 @@ export default function SettingsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass}>
|
||||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
Collapse,
|
Collapse,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
List,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
|
|
@ -258,7 +257,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
<Switch checked={fragment} onChange={setFragmentEnabled} />
|
<Switch checked={fragment} onChange={setFragmentEnabled} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
{fragment && (
|
{fragment && (
|
||||||
<List.Item className="nested-block">
|
<div className="nested-block">
|
||||||
<Collapse items={[
|
<Collapse items={[
|
||||||
{
|
{
|
||||||
key: 'sett',
|
key: 'sett',
|
||||||
|
|
@ -285,7 +284,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]} />
|
]} />
|
||||||
</List.Item>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|
@ -299,7 +298,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
|
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
{noisesEnabled && (
|
{noisesEnabled && (
|
||||||
<List.Item className="nested-block">
|
<div className="nested-block">
|
||||||
<Collapse items={noisesArray.map((noise, index) => ({
|
<Collapse items={noisesArray.map((noise, index) => ({
|
||||||
key: String(index),
|
key: String(index),
|
||||||
label: `Noise №${index + 1}`,
|
label: `Noise №${index + 1}`,
|
||||||
|
|
@ -340,7 +339,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
),
|
),
|
||||||
}))} />
|
}))} />
|
||||||
<Button type="primary" style={{ marginTop: 10 }} onClick={addNoise}>+ Noise</Button>
|
<Button type="primary" style={{ marginTop: 10 }} onClick={addNoise}>+ Noise</Button>
|
||||||
</List.Item>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|
@ -354,7 +353,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
<Switch checked={muxEnabled} onChange={setMuxEnabled} />
|
<Switch checked={muxEnabled} onChange={setMuxEnabled} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
{muxEnabled && (
|
{muxEnabled && (
|
||||||
<List.Item className="nested-block">
|
<div className="nested-block">
|
||||||
<Collapse items={[
|
<Collapse items={[
|
||||||
{
|
{
|
||||||
key: 'sett',
|
key: 'sett',
|
||||||
|
|
@ -381,7 +380,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]} />
|
]} />
|
||||||
</List.Item>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|
@ -395,7 +394,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
<Switch checked={directEnabled} onChange={setDirectEnabled} />
|
<Switch checked={directEnabled} onChange={setDirectEnabled} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
{directEnabled && (
|
{directEnabled && (
|
||||||
<List.Item className="nested-block">
|
<div className="nested-block">
|
||||||
<Collapse items={[
|
<Collapse items={[
|
||||||
{
|
{
|
||||||
key: 'rules',
|
key: 'rules',
|
||||||
|
|
@ -424,7 +423,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]} />
|
]} />
|
||||||
</List.Item>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export default function TwoFactorModal({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: TwoFactorModalProps) {
|
}: TwoFactorModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [enteredCode, setEnteredCode] = useState('');
|
const [enteredCode, setEnteredCode] = useState('');
|
||||||
const [qrValue, setQrValue] = useState('');
|
const [qrValue, setQrValue] = useState('');
|
||||||
const totpRef = useRef<OTPAuth.TOTP | null>(null);
|
const totpRef = useRef<OTPAuth.TOTP | null>(null);
|
||||||
|
|
@ -68,7 +69,7 @@ export default function TwoFactorModal({
|
||||||
if (totpRef.current.generate() === enteredCode) {
|
if (totpRef.current.generate() === enteredCode) {
|
||||||
close(true);
|
close(true);
|
||||||
} else {
|
} else {
|
||||||
message.error(t('pages.settings.security.twoFactorModalError'));
|
messageApi.error(t('pages.settings.security.twoFactorModalError'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,10 +79,12 @@ export default function TwoFactorModal({
|
||||||
|
|
||||||
async function copyToken() {
|
async function copyToken() {
|
||||||
const ok = await ClipboardManager.copyText(token);
|
const ok = await ClipboardManager.copyText(token);
|
||||||
if (ok) message.success(t('copied'));
|
if (ok) messageApi.success(t('copied'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={title}
|
title={title}
|
||||||
|
|
@ -125,5 +128,6 @@ export default function TwoFactorModal({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import './SubPage.css';
|
import './SubPage.css';
|
||||||
|
|
||||||
|
|
@ -78,6 +79,8 @@ function linkName(link: string, idx: number): string {
|
||||||
export default function SubPage() {
|
export default function SubPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
||||||
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth < 576);
|
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth < 576);
|
||||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
||||||
|
|
@ -109,8 +112,8 @@ export default function SubPage() {
|
||||||
const copy = useCallback(async (value: string) => {
|
const copy = useCallback(async (value: string) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
const ok = await ClipboardManager.copyText(value);
|
const ok = await ClipboardManager.copyText(value);
|
||||||
if (ok) message.success(t('copied'));
|
if (ok) messageApi.success(t('copied'));
|
||||||
}, [t]);
|
}, [t, messageApi]);
|
||||||
|
|
||||||
const open = useCallback((url: string) => {
|
const open = useCallback((url: string) => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
@ -273,6 +276,7 @@ export default function SubPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass}>
|
||||||
<Layout.Content className="content">
|
<Layout.Content className="content">
|
||||||
<Row justify="center">
|
<Row justify="center">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,30 @@
|
||||||
|
.preset-list {
|
||||||
|
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .preset-list,
|
||||||
|
html[data-theme='ultra-dark'] .preset-list {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.preset-row {
|
.preset-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .preset-row,
|
||||||
|
html[data-theme='ultra-dark'] .preset-row {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-name {
|
.preset-name {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, List, Modal, Space, Tag } from 'antd';
|
import { Button, Modal, Space, Tag } from 'antd';
|
||||||
import './DnsPresetsModal.css';
|
import './DnsPresetsModal.css';
|
||||||
|
|
||||||
interface DnsPresetsModalProps {
|
interface DnsPresetsModalProps {
|
||||||
|
|
@ -47,9 +47,9 @@ export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresets
|
||||||
mask={{ closable: false }}
|
mask={{ closable: false }}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
>
|
>
|
||||||
<List bordered>
|
<div className="preset-list">
|
||||||
{PRESETS.map((preset) => (
|
{PRESETS.map((preset) => (
|
||||||
<List.Item key={preset.name} className="preset-row">
|
<div key={preset.name} className="preset-row">
|
||||||
<Space size="small" align="center">
|
<Space size="small" align="center">
|
||||||
<Tag color={preset.family ? 'purple' : 'green'}>
|
<Tag color={preset.family ? 'purple' : 'green'}>
|
||||||
{preset.family ? t('pages.xray.dns.dnsPresetFamily') : 'DNS'}
|
{preset.family ? t('pages.xray.dns.dnsPresetFamily') : 'DNS'}
|
||||||
|
|
@ -59,9 +59,9 @@ export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresets
|
||||||
<Button type="primary" size="small" onClick={() => onInstall([...preset.data])}>
|
<Button type="primary" size="small" onClick={() => onInstall([...preset.data])}>
|
||||||
{t('install')}
|
{t('install')}
|
||||||
</Button>
|
</Button>
|
||||||
</List.Item>
|
</div>
|
||||||
))}
|
))}
|
||||||
</List>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Switch } from 'antd';
|
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
|
||||||
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
|
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
|
||||||
|
import InputAddon from '@/components/InputAddon';
|
||||||
|
|
||||||
export type DnsServerValue =
|
export type DnsServerValue =
|
||||||
| string
|
| string
|
||||||
|
|
@ -190,39 +191,45 @@ export default function DnsServerModal({
|
||||||
<Form.Item label={t('pages.xray.dns.domains')}>
|
<Form.Item label={t('pages.xray.dns.domains')}>
|
||||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('domains', (d) => d.push(''))} />
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('domains', (d) => d.push(''))} />
|
||||||
{form.domains.map((value, idx) => (
|
{form.domains.map((value, idx) => (
|
||||||
|
<Space.Compact key={`d${idx}`} block style={{ marginTop: 4 }}>
|
||||||
<Input
|
<Input
|
||||||
key={`d${idx}`}
|
|
||||||
value={value}
|
value={value}
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
onChange={(e) => updateList('domains', (d) => { d[idx] = e.target.value; })}
|
onChange={(e) => updateList('domains', (d) => { d[idx] = e.target.value; })}
|
||||||
addonAfter={<MinusOutlined onClick={() => updateList('domains', (d) => d.splice(idx, 1))} />}
|
|
||||||
/>
|
/>
|
||||||
|
<InputAddon onClick={() => updateList('domains', (d) => d.splice(idx, 1))}>
|
||||||
|
<MinusOutlined />
|
||||||
|
</InputAddon>
|
||||||
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t('pages.xray.dns.expectIPs')}>
|
<Form.Item label={t('pages.xray.dns.expectIPs')}>
|
||||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('expectedIPs', (d) => d.push(''))} />
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('expectedIPs', (d) => d.push(''))} />
|
||||||
{form.expectedIPs.map((value, idx) => (
|
{form.expectedIPs.map((value, idx) => (
|
||||||
|
<Space.Compact key={`e${idx}`} block style={{ marginTop: 4 }}>
|
||||||
<Input
|
<Input
|
||||||
key={`e${idx}`}
|
|
||||||
value={value}
|
value={value}
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
onChange={(e) => updateList('expectedIPs', (d) => { d[idx] = e.target.value; })}
|
onChange={(e) => updateList('expectedIPs', (d) => { d[idx] = e.target.value; })}
|
||||||
addonAfter={<MinusOutlined onClick={() => updateList('expectedIPs', (d) => d.splice(idx, 1))} />}
|
|
||||||
/>
|
/>
|
||||||
|
<InputAddon onClick={() => updateList('expectedIPs', (d) => d.splice(idx, 1))}>
|
||||||
|
<MinusOutlined />
|
||||||
|
</InputAddon>
|
||||||
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
|
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
|
||||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('unexpectedIPs', (d) => d.push(''))} />
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('unexpectedIPs', (d) => d.push(''))} />
|
||||||
{form.unexpectedIPs.map((value, idx) => (
|
{form.unexpectedIPs.map((value, idx) => (
|
||||||
|
<Space.Compact key={`u${idx}`} block style={{ marginTop: 4 }}>
|
||||||
<Input
|
<Input
|
||||||
key={`u${idx}`}
|
|
||||||
value={value}
|
value={value}
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
onChange={(e) => updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })}
|
onChange={(e) => updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })}
|
||||||
addonAfter={<MinusOutlined onClick={() => updateList('unexpectedIPs', (d) => d.splice(idx, 1))} />}
|
|
||||||
/>
|
/>
|
||||||
|
<InputAddon onClick={() => updateList('unexpectedIPs', (d) => d.splice(idx, 1))}>
|
||||||
|
<MinusOutlined />
|
||||||
|
</InputAddon>
|
||||||
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export default function NordModal({
|
||||||
onRemoveOutbound,
|
onRemoveOutbound,
|
||||||
onRemoveRoutingRules,
|
onRemoveRoutingRules,
|
||||||
}: NordModalProps) {
|
}: NordModalProps) {
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [nordData, setNordData] = useState<NordData | null>(null);
|
const [nordData, setNordData] = useState<NordData | null>(null);
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
|
|
@ -184,7 +185,7 @@ export default function NordModal({
|
||||||
})
|
})
|
||||||
.sort((a: NordServer, b: NordServer) => a.load - b.load);
|
.sort((a: NordServer, b: NordServer) => a.load - b.load);
|
||||||
setServers(next);
|
setServers(next);
|
||||||
if (next.length === 0) message.warning('No servers found for the selected country');
|
if (next.length === 0) messageApi.warning('No servers found for the selected country');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +197,7 @@ export default function NordModal({
|
||||||
const tech = server.technologies?.find((tt) => tt.id === 35);
|
const tech = server.technologies?.find((tt) => tt.id === 35);
|
||||||
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
|
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
message.error('Selected server does not advertise a NordLynx public key.');
|
messageApi.error('Selected server does not advertise a NordLynx public key.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -215,7 +216,7 @@ export default function NordModal({
|
||||||
const ob = buildNordOutbound();
|
const ob = buildNordOutbound();
|
||||||
if (!ob) return;
|
if (!ob) return;
|
||||||
onAddOutbound(ob);
|
onAddOutbound(ob);
|
||||||
message.success('NordVPN outbound added');
|
messageApi.success('NordVPN outbound added');
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,11 +231,13 @@ export default function NordModal({
|
||||||
oldTag,
|
oldTag,
|
||||||
newTag: ob.tag as string,
|
newTag: ob.tag as string,
|
||||||
});
|
});
|
||||||
message.success('NordVPN outbound updated');
|
messageApi.success('NordVPN outbound updated');
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
|
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
|
||||||
{nordData == null ? (
|
{nordData == null ? (
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|
@ -388,5 +391,6 @@ export default function NordModal({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { Wireguard } from '@/utils';
|
import { Wireguard } from '@/utils';
|
||||||
|
import InputAddon from '@/components/InputAddon';
|
||||||
import {
|
import {
|
||||||
Outbound,
|
Outbound,
|
||||||
Protocols,
|
Protocols,
|
||||||
|
|
@ -67,6 +68,7 @@ export default function OutboundFormModal({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: OutboundFormModalProps) {
|
}: OutboundFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const outboundRef = useRef<any>(null);
|
const outboundRef = useRef<any>(null);
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
|
|
@ -119,7 +121,7 @@ export default function OutboundFormModal({
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw);
|
parsed = JSON.parse(raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`JSON: ${(e as Error).message}`);
|
messageApi.error(`JSON: ${(e as Error).message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -130,7 +132,7 @@ export default function OutboundFormModal({
|
||||||
refresh();
|
refresh();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`JSON: ${(e as Error).message}`);
|
messageApi.error(`JSON: ${(e as Error).message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -219,11 +221,11 @@ export default function OutboundFormModal({
|
||||||
if (!ob) return;
|
if (!ob) return;
|
||||||
if (activeKey === '2' && !applyAdvancedJsonToForm()) return;
|
if (activeKey === '2' && !applyAdvancedJsonToForm()) return;
|
||||||
if (!ob.tag?.trim()) {
|
if (!ob.tag?.trim()) {
|
||||||
message.error('Tag is required');
|
messageApi.error('Tag is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (duplicateTag) {
|
if (duplicateTag) {
|
||||||
message.error('Tag already used by another outbound');
|
messageApi.error('Tag already used by another outbound');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onConfirm(ob.toJson());
|
onConfirm(ob.toJson());
|
||||||
|
|
@ -235,17 +237,17 @@ export default function OutboundFormModal({
|
||||||
try {
|
try {
|
||||||
const next = Outbound.fromLink(link);
|
const next = Outbound.fromLink(link);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
message.error('Wrong Link!');
|
messageApi.error('Wrong Link!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
outboundRef.current = next;
|
outboundRef.current = next;
|
||||||
primeAdvancedJson();
|
primeAdvancedJson();
|
||||||
setLinkInput('');
|
setLinkInput('');
|
||||||
message.success('Link imported successfully...');
|
messageApi.success('Link imported successfully...');
|
||||||
setActiveKey('1');
|
setActiveKey('1');
|
||||||
refresh();
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`Link parse: ${(e as Error).message}`);
|
messageApi.error(`Link parse: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,11 +258,16 @@ export default function OutboundFormModal({
|
||||||
|
|
||||||
if (!ob) {
|
if (!ob) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal open={open} title={title} footer={null} onCancel={onClose} />
|
<Modal open={open} title={title} footer={null} onCancel={onClose} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={title}
|
title={title}
|
||||||
|
|
@ -279,6 +286,7 @@ export default function OutboundFormModal({
|
||||||
key: '1',
|
key: '1',
|
||||||
label: t('pages.xray.basicTemplate'),
|
label: t('pages.xray.basicTemplate'),
|
||||||
children: (
|
children: (
|
||||||
|
<>
|
||||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||||
<Form.Item label={t('protocol')}>
|
<Form.Item label={t('protocol')}>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -423,11 +431,11 @@ export default function OutboundFormModal({
|
||||||
{ob.stream && <SockoptFields ob={ob} refresh={refresh} />}
|
{ob.stream && <SockoptFields ob={ob} refresh={refresh} />}
|
||||||
|
|
||||||
{ob.canEnableMux() && <MuxFields ob={ob} refresh={refresh} t={t} />}
|
{ob.canEnableMux() && <MuxFields ob={ob} refresh={refresh} t={t} />}
|
||||||
|
</Form>
|
||||||
{ob.stream && ob.canEnableStream() && (
|
{ob.stream && ob.canEnableStream() && (
|
||||||
<FinalMaskForm stream={ob.stream} protocol={proto} onChange={refresh} />
|
<FinalMaskForm stream={ob.stream} protocol={proto} onChange={refresh} />
|
||||||
)}
|
)}
|
||||||
</Form>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -454,6 +462,7 @@ export default function OutboundFormModal({
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -808,17 +817,17 @@ function WireguardFields({ ob, refresh, regenerate, t }: TFieldProps & { regener
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Allowed IPs">
|
<Form.Item label="Allowed IPs">
|
||||||
{(peer.allowedIPs || []).map((ip, idx) => (
|
{(peer.allowedIPs || []).map((ip, idx) => (
|
||||||
|
<Space.Compact key={idx} block style={{ marginBottom: 4 }}>
|
||||||
<Input
|
<Input
|
||||||
key={idx}
|
|
||||||
value={ip}
|
value={ip}
|
||||||
style={{ marginBottom: 4 }}
|
|
||||||
onChange={(e) => { peer.allowedIPs![idx] = e.target.value; refresh(); }}
|
onChange={(e) => { peer.allowedIPs![idx] = e.target.value; refresh(); }}
|
||||||
addonAfter={
|
|
||||||
(peer.allowedIPs || []).length > 1 ? (
|
|
||||||
<MinusOutlined onClick={() => { peer.allowedIPs!.splice(idx, 1); refresh(); }} />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
{(peer.allowedIPs || []).length > 1 && (
|
||||||
|
<InputAddon onClick={() => { peer.allowedIPs!.splice(idx, 1); refresh(); }}>
|
||||||
|
<MinusOutlined />
|
||||||
|
</InputAddon>
|
||||||
|
)}
|
||||||
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -1047,22 +1056,20 @@ function XhttpFields({ ob, refresh, t }: TFieldProps) {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(xh.headers as Array<{ name: string; value: string }>).map((header, idx) => (
|
{(xh.headers as Array<{ name: string; value: string }>).map((header, idx) => (
|
||||||
<Input.Group key={idx} compact className="mb-8">
|
<Space.Compact key={idx} block className="mb-8">
|
||||||
|
<InputAddon>{`${idx + 1}`}</InputAddon>
|
||||||
<Input
|
<Input
|
||||||
value={header.name}
|
value={header.name}
|
||||||
addonBefore={`${idx + 1}`}
|
|
||||||
style={{ width: '45%' }}
|
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
onChange={(e) => { header.name = e.target.value; refresh(); }}
|
onChange={(e) => { header.name = e.target.value; refresh(); }}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={header.value}
|
value={header.value}
|
||||||
style={{ width: '45%' }}
|
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
onChange={(e) => { header.value = e.target.value; refresh(); }}
|
onChange={(e) => { header.value = e.target.value; refresh(); }}
|
||||||
/>
|
/>
|
||||||
<Button icon={<MinusOutlined />} onClick={() => { xh.removeHeader(idx); refresh(); }} />
|
<Button icon={<MinusOutlined />} onClick={() => { xh.removeHeader(idx); refresh(); }} />
|
||||||
</Input.Group>
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form, Input, Modal, Select, Tooltip } from 'antd';
|
import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
|
||||||
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
|
import InputAddon from '@/components/InputAddon';
|
||||||
|
|
||||||
export interface RoutingRule {
|
export interface RoutingRule {
|
||||||
type?: string;
|
type?: string;
|
||||||
|
|
@ -207,11 +208,10 @@ export default function RuleFormModal({
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{form.attrs.map((attr, idx) => (
|
{form.attrs.map((attr, idx) => (
|
||||||
<Input.Group key={idx} compact className="mb-8">
|
<Space.Compact key={idx} block className="mb-8">
|
||||||
|
<InputAddon>{`${idx + 1}`}</InputAddon>
|
||||||
<Input
|
<Input
|
||||||
value={attr[0]}
|
value={attr[0]}
|
||||||
style={{ width: '45%' }}
|
|
||||||
addonBefore={`${idx + 1}`}
|
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
|
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
|
||||||
|
|
@ -220,7 +220,6 @@ export default function RuleFormModal({
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={attr[1]}
|
value={attr[1]}
|
||||||
style={{ width: '45%' }}
|
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
|
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
|
||||||
|
|
@ -231,7 +230,7 @@ export default function RuleFormModal({
|
||||||
icon={<MinusOutlined />}
|
icon={<MinusOutlined />}
|
||||||
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
|
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
|
||||||
/>
|
/>
|
||||||
</Input.Group>
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export default function WarpModal({
|
||||||
onResetOutbound,
|
onResetOutbound,
|
||||||
onRemoveOutbound,
|
onRemoveOutbound,
|
||||||
}: WarpModalProps) {
|
}: WarpModalProps) {
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [warpData, setWarpData] = useState<WarpData | null>(null);
|
const [warpData, setWarpData] = useState<WarpData | null>(null);
|
||||||
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
|
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
|
||||||
|
|
@ -191,7 +192,7 @@ export default function WarpModal({
|
||||||
|
|
||||||
function addOutbound() {
|
function addOutbound() {
|
||||||
if (!stagedOutbound) {
|
if (!stagedOutbound) {
|
||||||
message.warning('Fetch the WARP config first.');
|
messageApi.warning('Fetch the WARP config first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onAddOutbound(stagedOutbound);
|
onAddOutbound(stagedOutbound);
|
||||||
|
|
@ -207,6 +208,8 @@ export default function WarpModal({
|
||||||
const hasConfig = !ObjectUtil.isEmpty(warpConfig);
|
const hasConfig = !ObjectUtil.isEmpty(warpConfig);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
|
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
|
||||||
{!hasWarp ? (
|
{!hasWarp ? (
|
||||||
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
|
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
|
||||||
|
|
@ -349,5 +352,6 @@ export default function WarpModal({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { useXraySetting } from '@/hooks/useXraySetting';
|
||||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||||
import AppSidebar from '@/components/AppSidebar';
|
import AppSidebar from '@/components/AppSidebar';
|
||||||
import JsonEditor from '@/components/JsonEditor';
|
import JsonEditor from '@/components/JsonEditor';
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
|
|
||||||
import BasicsTab from './BasicsTab';
|
import BasicsTab from './BasicsTab';
|
||||||
import RoutingTab from './RoutingTab';
|
import RoutingTab from './RoutingTab';
|
||||||
|
|
@ -65,6 +66,8 @@ export default function XrayPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
const xs = useXraySetting();
|
const xs = useXraySetting();
|
||||||
const {
|
const {
|
||||||
fetched,
|
fetched,
|
||||||
|
|
@ -239,7 +242,7 @@ export default function XrayPage() {
|
||||||
try {
|
try {
|
||||||
JSON.parse(xraySetting);
|
JSON.parse(xraySetting);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(`Advanced JSON: ${(e as Error).message}`);
|
messageApi.error(`Advanced JSON: ${(e as Error).message}`);
|
||||||
setActiveTabKey('tpl-advanced');
|
setActiveTabKey('tpl-advanced');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -252,6 +255,7 @@ export default function XrayPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass}>
|
||||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { message as antMessage } from 'antd';
|
import { getMessage } from './messageBus';
|
||||||
|
|
||||||
export class Msg {
|
export class Msg {
|
||||||
constructor(success = false, msg = "", obj = null) {
|
constructor(success = false, msg = "", obj = null) {
|
||||||
|
|
@ -15,7 +15,7 @@ export class HttpUtil {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const messageType = msg.success ? 'success' : 'error';
|
const messageType = msg.success ? 'success' : 'error';
|
||||||
antMessage[messageType](msg.msg);
|
getMessage()[messageType](msg.msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
static _respToMsg(resp) {
|
static _respToMsg(resp) {
|
||||||
|
|
|
||||||
12
frontend/src/utils/messageBus.ts
Normal file
12
frontend/src/utils/messageBus.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { message as staticMessage } from 'antd';
|
||||||
|
import type { MessageInstance } from 'antd/es/message/interface';
|
||||||
|
|
||||||
|
let current: MessageInstance | typeof staticMessage = staticMessage;
|
||||||
|
|
||||||
|
export function setMessageInstance(instance: MessageInstance) {
|
||||||
|
current = instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessage(): MessageInstance | typeof staticMessage {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
@ -188,9 +188,6 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON-marshal the view-model so the SPA can read it as a plain
|
// JSON-marshal the view-model so the SPA can read it as a plain
|
||||||
// object on mount. PageData fields are already in the shape the Vue
|
|
||||||
// component expects, plus a `links` array carrying the rendered
|
|
||||||
// share URLs.
|
|
||||||
// The panel's "Calendar Type" setting decides whether the SubPage
|
// The panel's "Calendar Type" setting decides whether the SubPage
|
||||||
// renders dates in Gregorian or Jalali — surface it here so the SPA
|
// renders dates in Gregorian or Jalali — surface it here so the SPA
|
||||||
// can match the rest of the panel without a round-trip.
|
// can match the rest of the panel without a round-trip.
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
a.xraySettingController = NewXraySettingController(g)
|
a.xraySettingController = NewXraySettingController(g)
|
||||||
}
|
}
|
||||||
|
|
||||||
// All four panel pages now serve the Vue 3 builds from web/dist/
|
// The main panel's HTML routes serve the pre-built SPA pages from distFS,
|
||||||
// instead of rendering the legacy Go templates. Each handler is a
|
// instead of rendering the legacy Go templates. Each handler is a
|
||||||
// thin wrapper around serveDistPage so the basePath injection +
|
// thin wrapper around serveDistPage so the basePath injection +
|
||||||
// no-cache headers stay centralised.
|
// no-cache headers stay centralised.
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,7 @@ var i18nFS embed.FS
|
||||||
// distFS embeds the Vite-built frontend (web/dist/). Every user-facing
|
// distFS embeds the Vite-built frontend (web/dist/). Every user-facing
|
||||||
// HTML route is served straight out of this FS — the legacy Go
|
// HTML route is served straight out of this FS — the legacy Go
|
||||||
// templates and `web/assets/` tree are gone post-Phase 8.
|
// templates and `web/assets/` tree are gone post-Phase 8.
|
||||||
//
|
|
||||||
// `all:` is required so files whose names start with `_` are NOT
|
|
||||||
// silently excluded by go:embed's default rules. Vite/rolldown emits
|
|
||||||
// `_plugin-vue_export-helper-<hash>.js` for the @vitejs/plugin-vue
|
|
||||||
// runtime; without `all:` the chunk would be missing from the binary
|
|
||||||
// at runtime → 404 → blank-page boot failure.
|
|
||||||
//
|
|
||||||
//go:embed all:dist
|
//go:embed all:dist
|
||||||
var distFS embed.FS
|
var distFS embed.FS
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue