|
@@ -1,16 +1,17 @@
|
|
|
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, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
|
|
|
|
|
|
|
+import { Button, Divider, Form, Input, message, Modal, Select, Tabs } from 'antd';
|
|
|
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
|
|
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
|
|
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
|
|
|
|
|
|
import { HttpUtil } from '@/utils';
|
|
import { HttpUtil } from '@/utils';
|
|
|
import { FormField } from '@/components/form/rhf';
|
|
import { FormField } from '@/components/form/rhf';
|
|
|
|
|
+import { countryFlag, countryName } from '../outbounds/outbounds-tab-helpers';
|
|
|
import './NordModal.css';
|
|
import './NordModal.css';
|
|
|
|
|
|
|
|
interface NordModalProps {
|
|
interface NordModalProps {
|
|
|
open: boolean;
|
|
open: boolean;
|
|
|
- templateSettings: { outbounds?: { tag?: string }[] } | null;
|
|
|
|
|
|
|
+ templateSettings: { outbounds?: NordOutboundRow[] } | null;
|
|
|
onClose: () => void;
|
|
onClose: () => void;
|
|
|
onAddOutbound: (outbound: Record<string, unknown>) => void;
|
|
onAddOutbound: (outbound: Record<string, unknown>) => void;
|
|
|
onResetOutbound: (payload: {
|
|
onResetOutbound: (payload: {
|
|
@@ -19,8 +20,19 @@ interface NordModalProps {
|
|
|
oldTag?: string;
|
|
oldTag?: string;
|
|
|
newTag: string;
|
|
newTag: string;
|
|
|
}) => void;
|
|
}) => void;
|
|
|
- onRemoveOutbound: (index: number) => void;
|
|
|
|
|
- onRemoveRoutingRules: (payload: { prefix: string }) => void;
|
|
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface NordOutboundRow {
|
|
|
|
|
+ tag?: string;
|
|
|
|
|
+ protocol?: string;
|
|
|
|
|
+ settings?: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface NordAddedRow {
|
|
|
|
|
+ index: number;
|
|
|
|
|
+ tag: string;
|
|
|
|
|
+ endpoint: string;
|
|
|
|
|
+ resettable: boolean;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface NordData {
|
|
interface NordData {
|
|
@@ -45,12 +57,19 @@ interface NordServer {
|
|
|
hostname: string;
|
|
hostname: string;
|
|
|
station: string;
|
|
station: string;
|
|
|
load: number;
|
|
load: number;
|
|
|
- technologies?: { id: number; metadata?: { name: string; value: string }[] }[];
|
|
|
|
|
|
|
+ technologies?: { metadata?: { name: string; value: string }[] }[];
|
|
|
location_ids?: number[];
|
|
location_ids?: number[];
|
|
|
cityId?: number | null;
|
|
cityId?: number | null;
|
|
|
cityName?: string;
|
|
cityName?: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+interface NordServerOption {
|
|
|
|
|
+ value: number;
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ searchText: string;
|
|
|
|
|
+ server: NordServer;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
interface NordFormValues {
|
|
interface NordFormValues {
|
|
|
token: string;
|
|
token: string;
|
|
|
manualKey: string;
|
|
manualKey: string;
|
|
@@ -67,10 +86,30 @@ const EMPTY: NordFormValues = {
|
|
|
serverId: null,
|
|
serverId: null,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-function loadColor(load: number): string {
|
|
|
|
|
- if (load < 30) return 'green';
|
|
|
|
|
- if (load < 70) return 'orange';
|
|
|
|
|
- return 'red';
|
|
|
|
|
|
|
+function loadLevel(load: number): 'low' | 'medium' | 'high' {
|
|
|
|
|
+ if (load < 30) return 'low';
|
|
|
|
|
+ if (load < 70) return 'medium';
|
|
|
|
|
+ return 'high';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isResettableNordOutbound(outbound: NordOutboundRow): boolean {
|
|
|
|
|
+ if (outbound.protocol !== 'wireguard' || !isRecord(outbound.settings)) return false;
|
|
|
|
|
+ return (
|
|
|
|
|
+ Array.isArray(outbound.settings.address) &&
|
|
|
|
|
+ outbound.settings.address.length > 0 &&
|
|
|
|
|
+ Array.isArray(outbound.settings.peers) &&
|
|
|
|
|
+ outbound.settings.peers.length > 0
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function nordOutboundEndpoint(outbound: NordOutboundRow): string {
|
|
|
|
|
+ if (!isRecord(outbound.settings) || !Array.isArray(outbound.settings.peers)) return '';
|
|
|
|
|
+ const peer = outbound.settings.peers.find(isRecord);
|
|
|
|
|
+ return typeof peer?.endpoint === 'string' ? peer.endpoint : '';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export default function NordModal({
|
|
export default function NordModal({
|
|
@@ -79,10 +118,8 @@ export default function NordModal({
|
|
|
onClose,
|
|
onClose,
|
|
|
onAddOutbound,
|
|
onAddOutbound,
|
|
|
onResetOutbound,
|
|
onResetOutbound,
|
|
|
- onRemoveOutbound,
|
|
|
|
|
- onRemoveRoutingRules,
|
|
|
|
|
}: NordModalProps) {
|
|
}: NordModalProps) {
|
|
|
- const { t } = useTranslation();
|
|
|
|
|
|
|
+ const { t, i18n } = useTranslation();
|
|
|
const [messageApi, messageContextHolder] = message.useMessage();
|
|
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);
|
|
@@ -92,18 +129,47 @@ export default function NordModal({
|
|
|
const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
|
|
const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
|
|
|
const cityId = useWatch({ control: methods.control, name: 'cityId' });
|
|
const cityId = useWatch({ control: methods.control, name: 'cityId' });
|
|
|
const serverId = useWatch({ control: methods.control, name: 'serverId' });
|
|
const serverId = useWatch({ control: methods.control, name: 'serverId' });
|
|
|
|
|
+ const locale = i18n.resolvedLanguage || i18n.language;
|
|
|
|
|
|
|
|
- const nordOutboundIndex = useMemo(() => {
|
|
|
|
|
|
|
+ const nordRows = useMemo<NordAddedRow[]>(() => {
|
|
|
const list = templateSettings?.outbounds;
|
|
const list = templateSettings?.outbounds;
|
|
|
- if (!list) return -1;
|
|
|
|
|
- return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
|
|
|
|
|
|
|
+ if (!list) return [];
|
|
|
|
|
+ return list.flatMap((outbound, index) => {
|
|
|
|
|
+ const tag = outbound?.tag;
|
|
|
|
|
+ if (typeof tag !== 'string' || !tag.startsWith('nord-')) return [];
|
|
|
|
|
+ return [
|
|
|
|
|
+ {
|
|
|
|
|
+ index,
|
|
|
|
|
+ tag,
|
|
|
|
|
+ endpoint: nordOutboundEndpoint(outbound),
|
|
|
|
|
+ resettable: isResettableNordOutbound(outbound),
|
|
|
|
|
+ },
|
|
|
|
|
+ ];
|
|
|
|
|
+ });
|
|
|
}, [templateSettings?.outbounds]);
|
|
}, [templateSettings?.outbounds]);
|
|
|
|
|
|
|
|
|
|
+ const addedTags = useMemo(() => new Set(nordRows.map((row) => row.tag)), [nordRows]);
|
|
|
|
|
+
|
|
|
const filteredServers = useMemo(() => {
|
|
const filteredServers = useMemo(() => {
|
|
|
- if (!cityId) return servers;
|
|
|
|
|
|
|
+ if (cityId == null) return servers;
|
|
|
return servers.filter((s) => s.cityId === cityId);
|
|
return servers.filter((s) => s.cityId === cityId);
|
|
|
}, [cityId, servers]);
|
|
}, [cityId, servers]);
|
|
|
|
|
|
|
|
|
|
+ const selectedServer = filteredServers.find((server) => server.id === serverId);
|
|
|
|
|
+ const selectedTag = selectedServer ? `nord-${selectedServer.hostname}` : '';
|
|
|
|
|
+ const selectedAlreadyAdded = Boolean(selectedTag && addedTags.has(selectedTag));
|
|
|
|
|
+ const serverOptions = useMemo<NordServerOption[]>(
|
|
|
|
|
+ () =>
|
|
|
|
|
+ filteredServers.map((server) => ({
|
|
|
|
|
+ value: server.id,
|
|
|
|
|
+ label: server.hostname,
|
|
|
|
|
+ searchText:
|
|
|
|
|
+ `${server.cityName ?? ''} ${server.name} ${server.hostname} ${server.station}`.toLowerCase(),
|
|
|
|
|
+ server,
|
|
|
|
|
+ })),
|
|
|
|
|
+ [filteredServers],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
|
|
methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
|
|
|
}, [filteredServers, methods]);
|
|
}, [filteredServers, methods]);
|
|
@@ -174,8 +240,6 @@ export default function NordModal({
|
|
|
try {
|
|
try {
|
|
|
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
|
|
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
|
|
|
if (msg?.success) {
|
|
if (msg?.success) {
|
|
|
- onRemoveOutbound(nordOutboundIndex);
|
|
|
|
|
- onRemoveRoutingRules({ prefix: 'nord-' });
|
|
|
|
|
setNordData(null);
|
|
setNordData(null);
|
|
|
methods.reset(EMPTY);
|
|
methods.reset(EMPTY);
|
|
|
setCountries([]);
|
|
setCountries([]);
|
|
@@ -216,6 +280,7 @@ export default function NordModal({
|
|
|
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
|
|
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
|
|
|
})
|
|
})
|
|
|
.sort((a: NordServer, b: NordServer) => a.load - b.load);
|
|
.sort((a: NordServer, b: NordServer) => a.load - b.load);
|
|
|
|
|
+ methods.setValue('cityId', null);
|
|
|
setServers(next);
|
|
setServers(next);
|
|
|
if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers'));
|
|
if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers'));
|
|
|
} finally {
|
|
} finally {
|
|
@@ -227,8 +292,9 @@ export default function NordModal({
|
|
|
const selectedServerId = methods.getValues('serverId');
|
|
const selectedServerId = methods.getValues('serverId');
|
|
|
const server = servers.find((s) => s.id === selectedServerId);
|
|
const server = servers.find((s) => s.id === selectedServerId);
|
|
|
if (!server) return null;
|
|
if (!server) return null;
|
|
|
- const tech = server.technologies?.find((tt) => tt.id === 35);
|
|
|
|
|
- const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
|
|
|
|
|
|
|
+ const publicKey = server.technologies
|
|
|
|
|
+ ?.flatMap((technology) => technology.metadata ?? [])
|
|
|
|
|
+ .find((metadata) => metadata.name === 'public_key')?.value;
|
|
|
if (!publicKey) {
|
|
if (!publicKey) {
|
|
|
messageApi.error(t('pages.xray.nord.noPublicKey'));
|
|
messageApi.error(t('pages.xray.nord.noPublicKey'));
|
|
|
return null;
|
|
return null;
|
|
@@ -249,32 +315,49 @@ export default function NordModal({
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function addOutbound() {
|
|
function addOutbound() {
|
|
|
|
|
+ if (selectedAlreadyAdded) return;
|
|
|
const ob = buildNordOutbound();
|
|
const ob = buildNordOutbound();
|
|
|
if (!ob) return;
|
|
if (!ob) return;
|
|
|
|
|
+ const tag = typeof ob.tag === 'string' ? ob.tag : '';
|
|
|
|
|
+ if (tag && templateSettings?.outbounds?.some((outbound) => outbound?.tag === tag)) return;
|
|
|
onAddOutbound(ob);
|
|
onAddOutbound(ob);
|
|
|
messageApi.success(t('pages.xray.nord.outboundAdded'));
|
|
messageApi.success(t('pages.xray.nord.outboundAdded'));
|
|
|
- onClose();
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function resetOutbound() {
|
|
|
|
|
- if (nordOutboundIndex === -1) return;
|
|
|
|
|
- const ob = buildNordOutbound();
|
|
|
|
|
- if (!ob) return;
|
|
|
|
|
- const oldTag = templateSettings?.outbounds?.[nordOutboundIndex]?.tag;
|
|
|
|
|
|
|
+ function resetOutbound(index: number) {
|
|
|
|
|
+ const existing = templateSettings?.outbounds?.[index];
|
|
|
|
|
+ if (
|
|
|
|
|
+ !existing?.tag?.startsWith?.('nord-') ||
|
|
|
|
|
+ !isResettableNordOutbound(existing) ||
|
|
|
|
|
+ !isRecord(existing.settings) ||
|
|
|
|
|
+ !nordData?.private_key
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const ob = {
|
|
|
|
|
+ ...existing,
|
|
|
|
|
+ settings: { ...existing.settings, secretKey: nordData.private_key },
|
|
|
|
|
+ };
|
|
|
onResetOutbound({
|
|
onResetOutbound({
|
|
|
- index: nordOutboundIndex,
|
|
|
|
|
|
|
+ index,
|
|
|
outbound: ob,
|
|
outbound: ob,
|
|
|
- oldTag,
|
|
|
|
|
- newTag: ob.tag as string,
|
|
|
|
|
|
|
+ oldTag: existing.tag,
|
|
|
|
|
+ newTag: existing.tag,
|
|
|
});
|
|
});
|
|
|
messageApi.success(t('pages.xray.nord.outboundUpdated'));
|
|
messageApi.success(t('pages.xray.nord.outboundUpdated'));
|
|
|
- onClose();
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
{messageContextHolder}
|
|
{messageContextHolder}
|
|
|
- <Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
|
|
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ open={open}
|
|
|
|
|
+ title="NordVPN NordLynx"
|
|
|
|
|
+ footer={null}
|
|
|
|
|
+ width={680}
|
|
|
|
|
+ className="nord-modal"
|
|
|
|
|
+ onCancel={onClose}
|
|
|
|
|
+ >
|
|
|
<FormProvider {...methods}>
|
|
<FormProvider {...methods}>
|
|
|
{nordData == null ? (
|
|
{nordData == null ? (
|
|
|
<Tabs
|
|
<Tabs
|
|
@@ -284,18 +367,13 @@ export default function NordModal({
|
|
|
key: 'token',
|
|
key: 'token',
|
|
|
label: t('pages.xray.nord.accessToken'),
|
|
label: t('pages.xray.nord.accessToken'),
|
|
|
children: (
|
|
children: (
|
|
|
- <Form
|
|
|
|
|
- colon={false}
|
|
|
|
|
- labelCol={{ md: { span: 6 } }}
|
|
|
|
|
- wrapperCol={{ md: { span: 18 } }}
|
|
|
|
|
- className="mt-20"
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <Form colon={false} layout="vertical" className="nord-login-form">
|
|
|
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
|
|
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
|
|
|
<Input placeholder={t('pages.xray.nord.accessToken')} />
|
|
<Input placeholder={t('pages.xray.nord.accessToken')} />
|
|
|
</FormField>
|
|
</FormField>
|
|
|
<Button
|
|
<Button
|
|
|
type="primary"
|
|
type="primary"
|
|
|
- className="mt-10"
|
|
|
|
|
|
|
+ className="nord-login-action"
|
|
|
loading={loading}
|
|
loading={loading}
|
|
|
icon={<LoginOutlined />}
|
|
icon={<LoginOutlined />}
|
|
|
onClick={login}
|
|
onClick={login}
|
|
@@ -309,18 +387,13 @@ export default function NordModal({
|
|
|
key: 'key',
|
|
key: 'key',
|
|
|
label: t('pages.xray.nord.privateKey'),
|
|
label: t('pages.xray.nord.privateKey'),
|
|
|
children: (
|
|
children: (
|
|
|
- <Form
|
|
|
|
|
- colon={false}
|
|
|
|
|
- labelCol={{ md: { span: 6 } }}
|
|
|
|
|
- wrapperCol={{ md: { span: 18 } }}
|
|
|
|
|
- className="mt-20"
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <Form colon={false} layout="vertical" className="nord-login-form">
|
|
|
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
|
|
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
|
|
|
<Input placeholder={t('pages.xray.nord.privateKey')} />
|
|
<Input placeholder={t('pages.xray.nord.privateKey')} />
|
|
|
</FormField>
|
|
</FormField>
|
|
|
<Button
|
|
<Button
|
|
|
type="primary"
|
|
type="primary"
|
|
|
- className="mt-10"
|
|
|
|
|
|
|
+ className="nord-login-action"
|
|
|
loading={loading}
|
|
loading={loading}
|
|
|
icon={<SaveOutlined />}
|
|
icon={<SaveOutlined />}
|
|
|
onClick={saveKey}
|
|
onClick={saveKey}
|
|
@@ -334,109 +407,186 @@ export default function NordModal({
|
|
|
/>
|
|
/>
|
|
|
) : (
|
|
) : (
|
|
|
<>
|
|
<>
|
|
|
- <table className="nord-data-table">
|
|
|
|
|
- <tbody>
|
|
|
|
|
- {nordData.token && (
|
|
|
|
|
- <tr className="row-odd">
|
|
|
|
|
- <td>{t('pages.xray.nord.accessToken')}</td>
|
|
|
|
|
- <td>{nordData.token}</td>
|
|
|
|
|
|
|
+ <div className="nord-account-card">
|
|
|
|
|
+ <table className="nord-data-table">
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {nordData.token && (
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td>{t('pages.xray.nord.accessToken')}</td>
|
|
|
|
|
+ <td>{nordData.token}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td>{t('pages.xray.nord.privateKey')}</td>
|
|
|
|
|
+ <td>{nordData.private_key}</td>
|
|
|
</tr>
|
|
</tr>
|
|
|
- )}
|
|
|
|
|
- <tr>
|
|
|
|
|
- <td>{t('pages.xray.nord.privateKey')}</td>
|
|
|
|
|
- <td>{nordData.private_key}</td>
|
|
|
|
|
- </tr>
|
|
|
|
|
- </tbody>
|
|
|
|
|
- </table>
|
|
|
|
|
-
|
|
|
|
|
- <Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
|
|
|
|
|
- {t('logout')}
|
|
|
|
|
- </Button>
|
|
|
|
|
-
|
|
|
|
|
- <Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
|
|
|
|
-
|
|
|
|
|
- <Form
|
|
|
|
|
- colon={false}
|
|
|
|
|
- labelCol={{ md: { span: 6 } }}
|
|
|
|
|
- wrapperCol={{ md: { span: 18 } }}
|
|
|
|
|
- className="mt-10"
|
|
|
|
|
- >
|
|
|
|
|
- <FormField
|
|
|
|
|
- name="countryId"
|
|
|
|
|
- label={t('pages.xray.outbound.country')}
|
|
|
|
|
- transform={{ input: (v) => v ?? undefined }}
|
|
|
|
|
- onAfterChange={(v) => fetchServers(v as number)}
|
|
|
|
|
- >
|
|
|
|
|
- <Select
|
|
|
|
|
- showSearch={{ optionFilterProp: 'label' }}
|
|
|
|
|
- options={countries.map((c) => ({
|
|
|
|
|
- value: c.id,
|
|
|
|
|
- label: `${c.name} (${c.code})`,
|
|
|
|
|
- }))}
|
|
|
|
|
- />
|
|
|
|
|
- </FormField>
|
|
|
|
|
-
|
|
|
|
|
- {cities.length > 0 && (
|
|
|
|
|
- <FormField name="cityId" label={t('pages.xray.outbound.city')}>
|
|
|
|
|
- <Select
|
|
|
|
|
- showSearch={{ optionFilterProp: 'label' }}
|
|
|
|
|
- options={[
|
|
|
|
|
- { value: null, label: t('pages.xray.outbound.allCities') },
|
|
|
|
|
- ...cities.map((c) => ({ value: c.id, label: c.name })),
|
|
|
|
|
- ]}
|
|
|
|
|
- />
|
|
|
|
|
- </FormField>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ <Button loading={loading} danger onClick={logout}>
|
|
|
|
|
+ {t('logout')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <Divider className="nord-section-divider">{t('pages.xray.warp.settings')}</Divider>
|
|
|
|
|
|
|
|
- {filteredServers.length > 0 && (
|
|
|
|
|
- <FormField name="serverId" label={t('pages.xray.outbound.server')}>
|
|
|
|
|
|
|
+ <Form colon={false} layout="vertical" className="nord-location-form">
|
|
|
|
|
+ <div className="nord-location-grid">
|
|
|
|
|
+ <FormField
|
|
|
|
|
+ name="countryId"
|
|
|
|
|
+ label={t('pages.xray.outbound.country')}
|
|
|
|
|
+ transform={{ input: (v) => v ?? undefined }}
|
|
|
|
|
+ onAfterChange={(v) => fetchServers(v as number)}
|
|
|
|
|
+ >
|
|
|
<Select
|
|
<Select
|
|
|
|
|
+ data-testid="nord-country-select"
|
|
|
showSearch={{ optionFilterProp: 'label' }}
|
|
showSearch={{ optionFilterProp: 'label' }}
|
|
|
- options={filteredServers.map((s) => ({
|
|
|
|
|
- value: s.id,
|
|
|
|
|
- label: `${s.cityName} ${s.name} ${s.hostname}`,
|
|
|
|
|
- children: (
|
|
|
|
|
- <span className="server-row">
|
|
|
|
|
- <span className="server-name">
|
|
|
|
|
- {s.cityName} - {s.name}
|
|
|
|
|
- </span>
|
|
|
|
|
- <Tag color={loadColor(s.load)} className="server-load-tag">
|
|
|
|
|
- {s.load}%
|
|
|
|
|
- </Tag>
|
|
|
|
|
- </span>
|
|
|
|
|
- ),
|
|
|
|
|
- }))}
|
|
|
|
|
|
|
+ options={countries.map((c) => {
|
|
|
|
|
+ const name = countryName(c.code, locale) || c.name || c.code;
|
|
|
|
|
+ const flag = countryFlag(c.code);
|
|
|
|
|
+ return {
|
|
|
|
|
+ value: c.id,
|
|
|
|
|
+ label: `${flag ? `${flag} ` : ''}${name} (${c.code})`,
|
|
|
|
|
+ };
|
|
|
|
|
+ })}
|
|
|
/>
|
|
/>
|
|
|
</FormField>
|
|
</FormField>
|
|
|
- )}
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {cities.length > 0 && (
|
|
|
|
|
+ <FormField name="cityId" label={t('pages.xray.outbound.city')}>
|
|
|
|
|
+ <Select
|
|
|
|
|
+ data-testid="nord-city-select"
|
|
|
|
|
+ showSearch={{ optionFilterProp: 'label' }}
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { value: null, label: t('pages.xray.outbound.allCities') },
|
|
|
|
|
+ ...cities.map((c) => ({ value: c.id, label: c.name })),
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </FormField>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {filteredServers.length > 0 && (
|
|
|
|
|
+ <div className="nord-server-field">
|
|
|
|
|
+ <FormField name="serverId" label={t('pages.xray.outbound.server')}>
|
|
|
|
|
+ <Select<number, NordServerOption>
|
|
|
|
|
+ data-testid="nord-server-select"
|
|
|
|
|
+ classNames={{ popup: { root: 'nord-server-popup' } }}
|
|
|
|
|
+ listHeight={320}
|
|
|
|
|
+ listItemHeight={58}
|
|
|
|
|
+ options={serverOptions}
|
|
|
|
|
+ showSearch={{
|
|
|
|
|
+ filterOption: (input, option) =>
|
|
|
|
|
+ option?.searchText.includes(input.trim().toLowerCase()) ?? false,
|
|
|
|
|
+ }}
|
|
|
|
|
+ optionRender={(option) => {
|
|
|
|
|
+ const server = option.data.server;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="nord-server-option">
|
|
|
|
|
+ <span className="nord-server-option-copy">
|
|
|
|
|
+ <span className="nord-server-option-name">{server.name}</span>
|
|
|
|
|
+ <span className="nord-server-option-meta">
|
|
|
|
|
+ <span>{server.cityName}</span>
|
|
|
|
|
+ <span aria-hidden="true">·</span>
|
|
|
|
|
+ <span className="nord-server-option-hostname">
|
|
|
|
|
+ {server.hostname}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span aria-hidden="true">·</span>
|
|
|
|
|
+ <span className="nord-server-option-address">
|
|
|
|
|
+ {server.station}:51820
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ className={`nord-server-load nord-server-load-${loadLevel(server.load)}`}
|
|
|
|
|
+ title={`${t('pages.xray.nord.serverLoad')}: ${server.load}%`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="nord-server-load-dot" aria-hidden="true" />
|
|
|
|
|
+ <span className="nord-server-load-label">
|
|
|
|
|
+ {t('pages.xray.nord.serverLoad')}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="nord-server-load-value">{server.load}%</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }}
|
|
|
|
|
+ labelRender={() =>
|
|
|
|
|
+ selectedServer ? (
|
|
|
|
|
+ <span className="nord-selected-server">
|
|
|
|
|
+ <span className="nord-selected-server-name">
|
|
|
|
|
+ {selectedServer.name}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="nord-selected-server-hostname">
|
|
|
|
|
+ {selectedServer.hostname}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="nord-selected-server-address">
|
|
|
|
|
+ {selectedServer.station}:51820
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ className={`nord-server-load nord-server-load-${loadLevel(selectedServer.load)}`}
|
|
|
|
|
+ title={`${t('pages.xray.nord.serverLoad')}: ${selectedServer.load}%`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="nord-server-load-dot" aria-hidden="true" />
|
|
|
|
|
+ <span className="nord-server-load-value">
|
|
|
|
|
+ {selectedServer.load}%
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ) : null
|
|
|
|
|
+ }
|
|
|
|
|
+ />
|
|
|
|
|
+ </FormField>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
</Form>
|
|
</Form>
|
|
|
|
|
|
|
|
- <Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
|
|
|
|
|
- {nordOutboundIndex >= 0 ? (
|
|
|
|
|
- <>
|
|
|
|
|
- <Tag color="green">{t('enabled')}</Tag>
|
|
|
|
|
- <Button
|
|
|
|
|
- type="primary"
|
|
|
|
|
- danger
|
|
|
|
|
- loading={loading}
|
|
|
|
|
- className="ml-8"
|
|
|
|
|
- onClick={resetOutbound}
|
|
|
|
|
- >
|
|
|
|
|
- {t('reset')}
|
|
|
|
|
- </Button>
|
|
|
|
|
- </>
|
|
|
|
|
- ) : (
|
|
|
|
|
|
|
+ <div className="nord-add-actions">
|
|
|
|
|
+ <div className="nord-already-added" aria-live="polite">
|
|
|
|
|
+ {selectedAlreadyAdded
|
|
|
|
|
+ ? t('pages.xray.nord.alreadyAdded', { reset: t('reset') })
|
|
|
|
|
+ : null}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ disabled={!serverId || selectedAlreadyAdded}
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ onClick={addOutbound}
|
|
|
|
|
+ >
|
|
|
|
|
+ {t('pages.xray.warp.addOutbound')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {nordRows.length > 0 && (
|
|
|
<>
|
|
<>
|
|
|
- <Tag color="orange">{t('disabled')}</Tag>
|
|
|
|
|
- <Button
|
|
|
|
|
- type="primary"
|
|
|
|
|
- className="ml-8"
|
|
|
|
|
- disabled={!serverId}
|
|
|
|
|
- loading={loading}
|
|
|
|
|
- onClick={addOutbound}
|
|
|
|
|
- >
|
|
|
|
|
- {t('pages.xray.warp.addOutbound')}
|
|
|
|
|
- </Button>
|
|
|
|
|
|
|
+ <Divider className="nord-section-divider">
|
|
|
|
|
+ {t('pages.xray.nord.addedServers')}
|
|
|
|
|
+ </Divider>
|
|
|
|
|
+ <table className="nord-added-table" data-testid="nord-added-table">
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {nordRows.map((row) => (
|
|
|
|
|
+ <tr key={`${row.index}-${row.tag}`}>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <span className="nord-added-server-tag">{row.tag}</span>
|
|
|
|
|
+ {row.endpoint && (
|
|
|
|
|
+ <span className="nord-added-server-endpoint">{row.endpoint}</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ danger
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ disabled={!row.resettable}
|
|
|
|
|
+ data-testid={`nord-reset-${row.index}`}
|
|
|
|
|
+ onClick={() => resetOutbound(row.index)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {t('reset')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
</>
|
|
</>
|
|
|
)}
|
|
)}
|
|
|
</>
|
|
</>
|