4 Комити 5a7b3b7370 ... 5e9606aa4d

Аутор SHA1 Порука Датум
  MHSanaei 5e9606aa4d fix(script): stop logging an error when Enter accepts the default ACME port пре 1 дан
  MHSanaei f36f481e02 feat(db): add pgclient command to install or upgrade PostgreSQL client tools пре 1 дан
  MHSanaei de70ecb026 fix(db): probe dump readability before PostgreSQL import пре 1 дан
  MHSanaei ed66209e38 feat(outbound): add real-delay connection test mode пре 1 дан

+ 10 - 7
frontend/src/hooks/useXraySetting.ts

@@ -29,6 +29,8 @@ export function isUdpOutbound(outbound: unknown): boolean {
   return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
   return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
 }
 }
 
 
+export type OutboundTestMode = 'tcp' | 'http' | 'real';
+
 export type { OutboundTrafficRow, OutboundTestResult };
 export type { OutboundTrafficRow, OutboundTestResult };
 
 
 export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
 export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
@@ -289,7 +291,7 @@ export function useXraySetting(): UseXraySettingResult {
   const testOutbound = useCallback(
   const testOutbound = useCallback(
     async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
     async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
       if (!outbound) return null;
       if (!outbound) return null;
-      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
+      const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
       setOutboundTestStates((prev) => ({
       setOutboundTestStates((prev) => ({
         ...prev,
         ...prev,
         [index]: { testing: true, result: null, mode: effMode },
         [index]: { testing: true, result: null, mode: effMode },
@@ -306,7 +308,7 @@ export function useXraySetting(): UseXraySettingResult {
   const testSubscriptionOutbound = useCallback(
   const testSubscriptionOutbound = useCallback(
     async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
     async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
       if (!outbound || !tag) return null;
       if (!outbound || !tag) return null;
-      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
+      const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
       setSubscriptionTestStates((prev) => ({
       setSubscriptionTestStates((prev) => ({
         ...prev,
         ...prev,
         [tag]: { testing: true, result: null, mode: effMode },
         [tag]: { testing: true, result: null, mode: effMode },
@@ -334,6 +336,7 @@ export function useXraySetting(): UseXraySettingResult {
       // HTTP batches stay homogeneous (all template or all subscription) so a
       // HTTP batches stay homogeneous (all template or all subscription) so a
       // tag shared between a template and a subscription outbound can't collide
       // tag shared between a template and a subscription outbound can't collide
       // inside one batch, and each batch's results route to one state map.
       // inside one batch, and each batch's results route to one state map.
+      const probeMode = mode === 'real' ? 'real' : 'http';
       const httpTplQueue: { index: number; outbound: unknown }[] = [];
       const httpTplQueue: { index: number; outbound: unknown }[] = [];
       const httpSubQueue: { tag: string; outbound: unknown }[] = [];
       const httpSubQueue: { tag: string; outbound: unknown }[] = [];
       const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
       const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
@@ -342,7 +345,7 @@ export function useXraySetting(): UseXraySettingResult {
         // freedom ("direct") and dns aren't proxies — skip them in every mode.
         // freedom ("direct") and dns aren't proxies — skip them in every mode.
         if (proto === 'freedom' || proto === 'dns') return;
         if (proto === 'freedom' || proto === 'dns') return;
         if (kind === 'sub' && !tag) return;
         if (kind === 'sub' && !tag) return;
-        const toHttp = mode === 'http' || isUdpOutbound(ob);
+        const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
         if (kind === 'tpl') {
         if (kind === 'tpl') {
           if (toHttp) httpTplQueue.push({ index, outbound: ob });
           if (toHttp) httpTplQueue.push({ index, outbound: ob });
           else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
           else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
@@ -376,10 +379,10 @@ export function useXraySetting(): UseXraySettingResult {
           const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
           const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
           setOutboundTestStates((prev) => {
           setOutboundTestStates((prev) => {
             const next = { ...prev };
             const next = { ...prev };
-            for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
+            for (const item of chunk) next[item.index] = { testing: true, result: null, mode: probeMode };
             return next;
             return next;
           });
           });
-          const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
+          const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
           setOutboundTestStates((prev) => {
           setOutboundTestStates((prev) => {
             const next = { ...prev };
             const next = { ...prev };
             chunk.forEach((item, i) => {
             chunk.forEach((item, i) => {
@@ -394,10 +397,10 @@ export function useXraySetting(): UseXraySettingResult {
           const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
           const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
           setSubscriptionTestStates((prev) => {
           setSubscriptionTestStates((prev) => {
             const next = { ...prev };
             const next = { ...prev };
-            for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: 'http' };
+            for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: probeMode };
             return next;
             return next;
           });
           });
-          const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
+          const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
           setSubscriptionTestStates((prev) => {
           setSubscriptionTestStates((prev) => {
             const next = { ...prev };
             const next = { ...prev };
             chunk.forEach((item, i) => {
             chunk.forEach((item, i) => {

+ 2 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -1305,7 +1305,7 @@ export const sections: readonly Section[] = [
         params: [
         params: [
           { name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
           { name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
           { name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
           { name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
-          { name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for a fast dial-only probe (parallel-safe). Default/empty uses a full HTTP probe through a temp xray instance.' },
+          { name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for a fast dial-only probe (parallel-safe), "real" for a real-delay probe whose delay is the full request time including tunnel establishment. Default/empty uses a full HTTP probe reporting the warm per-request round-trip. Both HTTP variants run through a temp xray instance.' },
         ],
         ],
         body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
         body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
       },
       },
@@ -1316,7 +1316,7 @@ export const sections: readonly Section[] = [
         params: [
         params: [
           { name: 'outbounds', in: 'body (form)', type: 'string', desc: 'JSON array of outbound configs to test (required).' },
           { name: 'outbounds', in: 'body (form)', type: 'string', desc: 'JSON array of outbound configs to test (required).' },
           { name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
           { name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
-          { name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP). Default/empty routes a real HTTP request through each outbound.' },
+          { name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP), "real" for real-delay probes whose delay is the full request time including tunnel establishment. Default/empty routes an HTTP request through each outbound and reports the warm per-request round-trip.' },
         ],
         ],
         body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
         body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
       },
       },

+ 2 - 2
frontend/src/pages/xray/outbounds/OutboundCardList.tsx

@@ -13,7 +13,7 @@ import {
 
 
 import { SizeFormatter } from '@/utils';
 import { SizeFormatter } from '@/utils';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
-import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
+import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 
 import type { OutboundRow } from './outbounds-tab-types';
 import type { OutboundRow } from './outbounds-tab-types';
 import TestResultPopover from './TestResultPopover';
 import TestResultPopover from './TestResultPopover';
@@ -28,7 +28,7 @@ import {
 
 
 interface OutboundCardListProps {
 interface OutboundCardListProps {
   rows: OutboundRow[];
   rows: OutboundRow[];
-  testMode: 'tcp' | 'http';
+  testMode: OutboundTestMode;
   outboundsTraffic: OutboundTrafficRow[];
   outboundsTraffic: OutboundTrafficRow[];
   outboundTestStates: Record<number, OutboundTestState>;
   outboundTestStates: Record<number, OutboundTestState>;
   setFirst: (idx: number) => void;
   setFirst: (idx: number) => void;

+ 3 - 2
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -45,7 +45,7 @@ import OutboundFormModal from './OutboundFormModal';
 import { propagateOutboundTagRename } from '../basics/helpers';
 import { propagateOutboundTagRename } from '../basics/helpers';
 import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
 import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
 import DeletionImpactList from '../DeletionImpactList';
 import DeletionImpactList from '../DeletionImpactList';
-import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
+import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import './OutboundsTab.css';
 import './OutboundsTab.css';
 
 
 import type { OutboundRow } from './outbounds-tab-types';
 import type { OutboundRow } from './outbounds-tab-types';
@@ -110,7 +110,7 @@ export default function OutboundsTab({
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [modal, modalContextHolder] = Modal.useModal();
   const [modal, modalContextHolder] = Modal.useModal();
   const [messageApi, messageContextHolder] = message.useMessage();
   const [messageApi, messageContextHolder] = message.useMessage();
-  const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
+  const [testMode, setTestMode] = useState<OutboundTestMode>('tcp');
   const [modalOpen, setModalOpen] = useState(false);
   const [modalOpen, setModalOpen] = useState(false);
   const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
   const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
   const [editingIndex, setEditingIndex] = useState<number | null>(null);
   const [editingIndex, setEditingIndex] = useState<number | null>(null);
@@ -486,6 +486,7 @@ export default function OutboundsTab({
                 <Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
                 <Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
                   <Radio.Button value="tcp">TCP</Radio.Button>
                   <Radio.Button value="tcp">TCP</Radio.Button>
                   <Radio.Button value="http">HTTP</Radio.Button>
                   <Radio.Button value="http">HTTP</Radio.Button>
+                  <Radio.Button value="real">{t('pages.xray.outbound.modeRealDelay')}</Radio.Button>
                 </Radio.Group>
                 </Radio.Group>
               </Tooltip>
               </Tooltip>
               <Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
               <Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>

+ 5 - 4
frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx

@@ -6,16 +6,17 @@ import type { ColumnsType } from 'antd/es/table';
 
 
 import { SizeFormatter } from '@/utils';
 import { SizeFormatter } from '@/utils';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
-import { isUdpOutbound } from '@/hooks/useXraySetting';
-import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
+import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 
 import type { OutboundRow } from './outbounds-tab-types';
 import type { OutboundRow } from './outbounds-tab-types';
 import TestResultPopover from './TestResultPopover';
 import TestResultPopover from './TestResultPopover';
 import {
 import {
+  effectiveTestMode,
   isTesting,
   isTesting,
   isUntestable,
   isUntestable,
   outboundAddresses,
   outboundAddresses,
   showSecurity,
   showSecurity,
+  testModeLabel,
   testResult,
   testResult,
   trafficFor,
   trafficFor,
 } from './outbounds-tab-helpers';
 } from './outbounds-tab-helpers';
@@ -24,7 +25,7 @@ interface SubscriptionOutboundsProps {
   subscriptionOutbounds: unknown[];
   subscriptionOutbounds: unknown[];
   outboundsTraffic: OutboundTrafficRow[];
   outboundsTraffic: OutboundTrafficRow[];
   subscriptionTestStates: Record<string, OutboundTestState>;
   subscriptionTestStates: Record<string, OutboundTestState>;
-  testMode: 'tcp' | 'http';
+  testMode: OutboundTestMode;
   isMobile: boolean;
   isMobile: boolean;
   onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
   onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
 }
 }
@@ -104,7 +105,7 @@ export default function SubscriptionOutbounds({
   const testButton = (record: OutboundRow) => {
   const testButton = (record: OutboundRow) => {
     const key = record.tag || '';
     const key = record.tag || '';
     return (
     return (
-      <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
+      <Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
         <Button
         <Button
           aria-label={t('check')}
           aria-label={t('check')}
           type="primary"
           type="primary"

+ 3 - 1
frontend/src/pages/xray/outbounds/TestResultPopover.tsx

@@ -5,6 +5,8 @@ import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
 
 
 import type { OutboundTestResult } from '@/hooks/useXraySetting';
 import type { OutboundTestResult } from '@/hooks/useXraySetting';
 
 
+import { testModeLabel } from './outbounds-tab-helpers';
+
 interface TestResultPopoverProps {
 interface TestResultPopoverProps {
   result: OutboundTestResult;
   result: OutboundTestResult;
   // Custom trigger element; defaults to the ok/fail latency pill.
   // Custom trigger element; defaults to the ok/fail latency pill.
@@ -39,7 +41,7 @@ export default function TestResultPopover({ result: r, children }: TestResultPop
         <div className="timing-breakdown">
         <div className="timing-breakdown">
           <div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
           <div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
             {r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
             {r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
-            {r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
+            {r.mode && <span className="mode-badge">{testModeLabel(String(r.mode), t)}</span>}
           </div>
           </div>
           {(r.endpoints || []).map((ep) => (
           {(r.endpoints || []).map((ep) => (
             <div key={ep.address} className="endpoint-row">
             <div key={ep.address} className="endpoint-row">

+ 12 - 1
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

@@ -1,5 +1,8 @@
+import type { TFunction } from 'i18next';
+
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
-import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
+import { isUdpOutbound } from '@/hooks/useXraySetting';
+import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 
 import type { OutboundRow } from './outbounds-tab-types';
 import type { OutboundRow } from './outbounds-tab-types';
 
 
@@ -45,6 +48,14 @@ export function showSecurity(security?: string): boolean {
   return security === 'tls' || security === 'reality';
   return security === 'tls' || security === 'reality';
 }
 }
 
 
+export function effectiveTestMode(o: unknown, mode: OutboundTestMode): OutboundTestMode {
+  return mode === 'tcp' && isUdpOutbound(o) ? 'http' : mode;
+}
+
+export function testModeLabel(mode: string, t: TFunction): string {
+  return mode === 'real' ? t('pages.xray.outbound.modeRealDelay') : mode.toUpperCase();
+}
+
 export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
 export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
   const tr = outboundsTraffic.find((x) => x.tag === o.tag);
   const tr = outboundsTraffic.find((x) => x.tag === o.tag);
   return { up: tr?.up || 0, down: tr?.down || 0 };
   return { up: tr?.up || 0, down: tr?.down || 0 };

+ 5 - 4
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx

@@ -16,22 +16,23 @@ import type { ColumnsType } from 'antd/es/table';
 
 
 import { SizeFormatter } from '@/utils';
 import { SizeFormatter } from '@/utils';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
-import { isUdpOutbound } from '@/hooks/useXraySetting';
-import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
+import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 
 import type { OutboundRow } from './outbounds-tab-types';
 import type { OutboundRow } from './outbounds-tab-types';
 import TestResultPopover from './TestResultPopover';
 import TestResultPopover from './TestResultPopover';
 import {
 import {
+  effectiveTestMode,
   isTesting,
   isTesting,
   isUntestable,
   isUntestable,
   outboundAddresses,
   outboundAddresses,
   showSecurity,
   showSecurity,
+  testModeLabel,
   testResult,
   testResult,
   trafficFor,
   trafficFor,
 } from './outbounds-tab-helpers';
 } from './outbounds-tab-helpers';
 
 
 interface OutboundColumnsParams {
 interface OutboundColumnsParams {
-  testMode: 'tcp' | 'http';
+  testMode: OutboundTestMode;
   rows: OutboundRow[];
   rows: OutboundRow[];
   outboundsTraffic: OutboundTrafficRow[];
   outboundsTraffic: OutboundTrafficRow[];
   outboundTestStates: Record<number, OutboundTestState>;
   outboundTestStates: Record<number, OutboundTestState>;
@@ -167,7 +168,7 @@ export function useOutboundColumns({
         align: 'center',
         align: 'center',
         width: 80,
         width: 80,
         render: (_v, record, index) => (
         render: (_v, record, index) => (
-          <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
+          <Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
             <Button
             <Button
               type="primary"
               type="primary"
               shape="circle"
               shape="circle"

+ 3 - 1
install.sh

@@ -679,7 +679,9 @@ ssl_cert_issue() {
     # get the port number for the standalone server
     # get the port number for the standalone server
     local WebPort=80
     local WebPort=80
     prompt_or_default WebPort "Please choose which port to use (default is 80): " "80" XUI_ACME_HTTP_PORT
     prompt_or_default WebPort "Please choose which port to use (default is 80): " "80" XUI_ACME_HTTP_PORT
-    if [[ ${WebPort} -gt 65535 || ${WebPort} -lt 1 ]]; then
+    if [[ -z ${WebPort} ]]; then
+        WebPort=80
+    elif [[ ! ${WebPort} =~ ^[1-9][0-9]*$ || ${WebPort} -gt 65535 ]]; then
         echo -e "${yellow}Your input ${WebPort} is invalid, will use default port 80.${plain}"
         echo -e "${yellow}Your input ${WebPort} is invalid, will use default port 80.${plain}"
         WebPort=80
         WebPort=80
     fi
     fi

+ 4 - 4
internal/web/controller/xray_setting.go

@@ -258,8 +258,8 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
 
 
 // testOutbound tests an outbound configuration and returns the delay/response time.
 // testOutbound tests an outbound configuration and returns the delay/response time.
 // Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
 // Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
-// Optional form "mode": "tcp" for a fast dial-only probe (parallel-safe),
-// anything else (default) for a full HTTP probe through a temp xray instance.
+// Optional form "mode": "tcp" for a fast dial-only probe, "real" for the cold
+// full-request delay, anything else (default) for a full HTTP probe through a temp xray instance.
 func (a *XraySettingController) testOutbound(c *gin.Context) {
 func (a *XraySettingController) testOutbound(c *gin.Context) {
 	outboundJSON := c.PostForm("outbound")
 	outboundJSON := c.PostForm("outbound")
 	allOutboundsJSON := c.PostForm("allOutbounds")
 	allOutboundsJSON := c.PostForm("allOutbounds")
@@ -291,8 +291,8 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
 // temp xray instance and returns an array of results in input order.
 // temp xray instance and returns an array of results in input order.
 // Form "outbounds": JSON array of outbound configs (required).
 // Form "outbounds": JSON array of outbound configs (required).
 // Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
 // Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
-// Optional form "mode": "tcp" for fast dial-only probes, anything else
-// (default) for real HTTP requests routed through each outbound.
+// Optional form "mode": "tcp" for fast dial-only probes, "real" for the cold
+// full-request delay, anything else (default) for real HTTP requests routed through each outbound.
 func (a *XraySettingController) testOutbounds(c *gin.Context) {
 func (a *XraySettingController) testOutbounds(c *gin.Context) {
 	outboundsJSON := c.PostForm("outbounds")
 	outboundsJSON := c.PostForm("outbounds")
 	allOutboundsJSON := c.PostForm("allOutbounds")
 	allOutboundsJSON := c.PostForm("allOutbounds")

+ 27 - 17
internal/web/service/outbound/probe_http.go

@@ -32,7 +32,8 @@ import (
 // spawn per batch instead of one per outbound. The reported delay comes from
 // spawn per batch instead of one per outbound. The reported delay comes from
 // a second request on the kept-alive connection, so it reflects the tunnel's
 // a second request on the kept-alive connection, so it reflects the tunnel's
 // real per-request round-trip rather than the stacked SOCKS/proxy/TLS
 // real per-request round-trip rather than the stacked SOCKS/proxy/TLS
-// handshakes of connection establishment.
+// handshakes of connection establishment. Mode "real" instead reports the
+// cold request's full elapsed time and skips the warm request.
 
 
 const (
 const (
 	// httpProbeTimeout bounds each probe request end-to-end (a probe makes
 	// httpProbeTimeout bounds each probe request end-to-end (a probe makes
@@ -84,6 +85,15 @@ type httpBatchItem struct {
 	result   *TestOutboundResult
 	result   *TestOutboundResult
 }
 }
 
 
+func probeModeLabel(mode string) string {
+	switch mode {
+	case "tcp", "real":
+		return mode
+	default:
+		return "http"
+	}
+}
+
 // TestOutbound probes a single outbound; legacy single-test API kept for the
 // TestOutbound probes a single outbound; legacy single-test API kept for the
 // /testOutbound endpoint. Dispatch matches TestOutbounds: mode "tcp" dials
 // /testOutbound endpoint. Dispatch matches TestOutbounds: mode "tcp" dials
 // the outbound's endpoints directly, anything else routes a real HTTP request
 // the outbound's endpoints directly, anything else routes a real HTTP request
@@ -92,11 +102,7 @@ type httpBatchItem struct {
 func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
 func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
 	var ob map[string]any
 	var ob map[string]any
 	if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
 	if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
-		m := "http"
-		if mode == "tcp" {
-			m = "tcp"
-		}
-		return &TestOutboundResult{Mode: m, Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
+		return &TestOutboundResult{Mode: probeModeLabel(mode), Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
 	}
 	}
 	results := s.testOutboundsParsed([]map[string]any{ob}, testURL, allOutboundsJSON, mode)
 	results := s.testOutboundsParsed([]map[string]any{ob}, testURL, allOutboundsJSON, mode)
 	return results[0], nil
 	return results[0], nil
@@ -130,10 +136,12 @@ func (s *OutboundService) TestOutbounds(outboundsJSON string, testURL string, al
 func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL string, allOutboundsJSON string, mode string) []*TestOutboundResult {
 func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL string, allOutboundsJSON string, mode string) []*TestOutboundResult {
 	results := make([]*TestOutboundResult, len(items))
 	results := make([]*TestOutboundResult, len(items))
 
 
-	modeLabel := "http"
-	if mode == "tcp" {
-		modeLabel = "tcp"
+	modeLabel := probeModeLabel(mode)
+	probeLabel := modeLabel
+	if probeLabel == "tcp" {
+		probeLabel = "http"
 	}
 	}
+	realDelay := mode == "real"
 
 
 	type tcpEntry struct {
 	type tcpEntry struct {
 		idx int
 		idx int
@@ -158,7 +166,7 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
 		}
 		}
 
 
 		tag, _ := ob["tag"].(string)
 		tag, _ := ob["tag"].(string)
-		r := &TestOutboundResult{Tag: tag, Mode: "http"}
+		r := &TestOutboundResult{Tag: tag, Mode: probeLabel}
 		results[i] = r
 		results[i] = r
 		protocol, _ := ob["protocol"].(string)
 		protocol, _ := ob["protocol"].(string)
 		switch {
 		switch {
@@ -231,7 +239,7 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
 	}
 	}
 	defer httpTestSemaphore.Unlock()
 	defer httpTestSemaphore.Unlock()
 
 
-	retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL)
+	retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL, realDelay)
 	if err == nil {
 	if err == nil {
 		return results
 		return results
 	}
 	}
@@ -244,7 +252,7 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
 	// instance so the broken outbound reports xray's real error and the
 	// instance so the broken outbound reports xray's real error and the
 	// rest still get tested. Serial: the poisoned case fails fast (~1s).
 	// rest still get tested. Serial: the poisoned case fails fast (~1s).
 	for _, it := range httpItems {
 	for _, it := range httpItems {
-		if _, ferr := runHTTPProbeBatch([]*httpBatchItem{it}, allOutbounds, testURL); ferr != nil {
+		if _, ferr := runHTTPProbeBatch([]*httpBatchItem{it}, allOutbounds, testURL, realDelay); ferr != nil {
 			it.result.Success = false
 			it.result.Success = false
 			it.result.Error = ferr.Error()
 			it.result.Error = ferr.Error()
 		}
 		}
@@ -258,7 +266,7 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
 // whether splitting the batch into per-item instances could help (true for
 // whether splitting the batch into per-item instances could help (true for
 // start failures / early exits that a poisoned config would explain, false
 // start failures / early exits that a poisoned config would explain, false
 // for environmental failures like a missing binary or no free ports).
 // for environmental failures like a missing binary or no free ports).
-func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string) (retryPerItem bool, err error) {
+func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string, realDelay bool) (retryPerItem bool, err error) {
 	ports, release, err := reserveLoopbackPorts(len(items))
 	ports, release, err := reserveLoopbackPorts(len(items))
 	if err != nil {
 	if err != nil {
 		return false, fmt.Errorf("Failed to reserve test ports: %w", err)
 		return false, fmt.Errorf("Failed to reserve test ports: %w", err)
@@ -304,7 +312,7 @@ func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL strin
 			defer wg.Done()
 			defer wg.Done()
 			sem <- struct{}{}
 			sem <- struct{}{}
 			defer func() { <-sem }()
 			defer func() { <-sem }()
-			probeThroughSocks(port, testURL, httpProbeTimeout, it.result)
+			probeThroughSocks(port, testURL, httpProbeTimeout, realDelay, it.result)
 		}(items[i], ports[i])
 		}(items[i], ports[i])
 	}
 	}
 	wg.Wait()
 	wg.Wait()
@@ -444,7 +452,7 @@ func outboundsContainTag(outbounds []any, tag string) bool {
 // the established tunnel — falling back to the cold total if the warm request
 // the established tunnel — falling back to the cold total if the warm request
 // fails. The test URL's hostname is resolved by xray (Go's SOCKS5 client
 // fails. The test URL's hostname is resolved by xray (Go's SOCKS5 client
 // sends the domain to the proxy), so DNS goes through the outbound too.
 // sends the domain to the proxy), so DNS goes through the outbound too.
-func probeThroughSocks(port int, testURL string, timeout time.Duration, result *TestOutboundResult) {
+func probeThroughSocks(port int, testURL string, timeout time.Duration, realDelay bool, result *TestOutboundResult) {
 	proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
 	proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
 	tr := &http.Transport{
 	tr := &http.Transport{
 		Proxy:               http.ProxyURL(proxyURL),
 		Proxy:               http.ProxyURL(proxyURL),
@@ -528,8 +536,10 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, result *
 	}
 	}
 
 
 	delay := coldDelay
 	delay := coldDelay
-	if warmDelay, ok := timedWarmGet(client, testURL); ok {
-		delay = warmDelay
+	if !realDelay {
+		if warmDelay, ok := timedWarmGet(client, testURL); ok {
+			delay = warmDelay
+		}
 	}
 	}
 	result.Delay = max(delay, 1)
 	result.Delay = max(delay, 1)
 }
 }

+ 89 - 1
internal/web/service/outbound/probe_http_test.go

@@ -464,6 +464,94 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestTestOutboundsRealDelayBatchThroughStubSocks(t *testing.T) {
+	var mu sync.Mutex
+	requestsPerConn := make(map[string]int)
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		mu.Lock()
+		requestsPerConn[r.RemoteAddr]++
+		mu.Unlock()
+		w.WriteHeader(http.StatusNoContent)
+	}))
+	defer srv.Close()
+
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		return &stubProcess{cfg: cfg, serveSocks: true}
+	})
+
+	batch := mustJSON(t, []any{
+		map[string]any{"tag": "a", "protocol": "vless"},
+		map[string]any{"tag": "wg", "protocol": "wireguard"},
+	})
+	results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "real")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	for i, r := range results {
+		if !r.Success {
+			t.Fatalf("result %d failed: %+v", i, r)
+		}
+		if r.Mode != "real" {
+			t.Errorf("result %d mode = %q, want %q", i, r.Mode, "real")
+		}
+		if r.HTTPStatus != http.StatusNoContent {
+			t.Errorf("result %d status = %d, want 204", i, r.HTTPStatus)
+		}
+		if r.Delay < 1 || r.ConnectMs < 1 || r.TTFBMs < 1 {
+			t.Errorf("result %d timing not populated: %+v", i, r)
+		}
+	}
+
+	mu.Lock()
+	defer mu.Unlock()
+	totalRequests := 0
+	for addr, n := range requestsPerConn {
+		totalRequests += n
+		if n != 1 {
+			t.Errorf("connection %s served %d requests, want 1 (real mode must skip the warm request)", addr, n)
+		}
+	}
+	if totalRequests != 2 {
+		t.Errorf("test URL served %d requests, want 2 (one cold request per probe)", totalRequests)
+	}
+}
+
+func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNoContent)
+	}))
+	defer srv.Close()
+
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		return &stubProcess{cfg: cfg, serveSocks: true}
+	})
+
+	batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "wireguard"}})
+	results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	r := results[0]
+	if !r.Success || r.Mode != "http" {
+		t.Errorf("UDP outbound in tcp mode = %+v, want success with mode %q", r, "http")
+	}
+}
+
+func TestProbeModeLabel(t *testing.T) {
+	cases := []struct{ mode, want string }{
+		{"tcp", "tcp"},
+		{"real", "real"},
+		{"http", "http"},
+		{"", "http"},
+		{"bogus", "http"},
+	}
+	for _, c := range cases {
+		if got := probeModeLabel(c.mode); got != c.want {
+			t.Errorf("probeModeLabel(%q) = %q, want %q", c.mode, got, c.want)
+		}
+	}
+}
+
 func TestProbeThroughSocksTransportFailure(t *testing.T) {
 func TestProbeThroughSocksTransportFailure(t *testing.T) {
 	// A listener that accepts and immediately closes — SOCKS handshake dies.
 	// A listener that accepts and immediately closes — SOCKS handshake dies.
 	l, err := net.Listen("tcp", "127.0.0.1:0")
 	l, err := net.Listen("tcp", "127.0.0.1:0")
@@ -482,7 +570,7 @@ func TestProbeThroughSocksTransportFailure(t *testing.T) {
 	}()
 	}()
 
 
 	var result TestOutboundResult
 	var result TestOutboundResult
-	probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, &result)
+	probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, false, &result)
 	if result.Success || result.Error == "" {
 	if result.Success || result.Error == "" {
 		t.Errorf("expected transport failure, got %+v", result)
 		t.Errorf("expected transport failure, got %+v", result)
 	}
 	}

+ 53 - 0
internal/web/service/server.go

@@ -1608,6 +1608,55 @@ func (s *ServerService) exportPostgresDB() ([]byte, error) {
 	return out.Bytes(), nil
 	return out.Bytes(), nil
 }
 }
 
 
+var (
+	pgUnsupportedDumpVersionPattern = regexp.MustCompile(`unsupported version \((\d+\.\d+)\) in file header`)
+	pgToolVersionPattern            = regexp.MustCompile(`\d+(?:\.\d+)+`)
+)
+
+var pgArchiveVersionIntroducedIn = map[string]int{
+	"1.15": 16,
+	"1.16": 17,
+}
+
+// checkPgRestoreCanRead probes the dump with pg_restore --list (reads only the
+// TOC, no database connection) so an unreadable file fails before Xray is stopped.
+func checkPgRestoreCanRead(bin, dumpPath string) error {
+	cmd := exec.CommandContext(context.Background(), bin, "--list", dumpPath)
+	cmd.Stdout = io.Discard
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	if cmd.Run() == nil {
+		return nil
+	}
+	return pgRestoreReadFailureError(strings.TrimSpace(stderr.String()), pgRestoreVersion(bin))
+}
+
+func pgRestoreReadFailureError(probeOutput, localVersion string) error {
+	m := pgUnsupportedDumpVersionPattern.FindStringSubmatch(probeOutput)
+	if m == nil {
+		return common.NewErrorf("pg_restore cannot read this dump file: %s", probeOutput)
+	}
+	if localVersion == "" {
+		localVersion = "unknown"
+	}
+	if major, known := pgArchiveVersionIntroducedIn[m[1]]; known {
+		return common.NewErrorf("This backup was created by pg_dump from PostgreSQL %d or newer, but the server's pg_restore is version %s and cannot read it; run 'x-ui pgclient %d' on the server (or upgrade the postgresql-client package to version %d or newer), then retry the import", major, localVersion, major, major)
+	}
+	return common.NewErrorf("This backup was created by a newer pg_dump than the server's pg_restore (version %s) can read; upgrade the postgresql-client package and retry the import", localVersion)
+}
+
+func pgRestoreVersion(bin string) string {
+	out, err := exec.CommandContext(context.Background(), bin, "--version").Output()
+	if err != nil {
+		return ""
+	}
+	return parsePgToolVersion(string(out))
+}
+
+func parsePgToolVersion(versionOutput string) string {
+	return pgToolVersionPattern.FindString(versionOutput)
+}
+
 func (s *ServerService) importPostgresDB(file multipart.File) error {
 func (s *ServerService) importPostgresDB(file multipart.File) error {
 	header := make([]byte, 5)
 	header := make([]byte, 5)
 	if _, err := file.ReadAt(header, 0); err != nil {
 	if _, err := file.ReadAt(header, 0); err != nil {
@@ -1643,6 +1692,10 @@ func (s *ServerService) importPostgresDB(file multipart.File) error {
 		return common.NewErrorf("Error closing temporary dump file: %v", err)
 		return common.NewErrorf("Error closing temporary dump file: %v", err)
 	}
 	}
 
 
+	if err := checkPgRestoreCanRead(bin, tempPath); err != nil {
+		return err
+	}
+
 	xrayStopped := true
 	xrayStopped := true
 	defer func() {
 	defer func() {
 		if xrayStopped {
 		if xrayStopped {

+ 75 - 0
internal/web/service/server_pg_restore_test.go

@@ -0,0 +1,75 @@
+package service
+
+import "testing"
+
+func TestPgRestoreReadFailureError(t *testing.T) {
+	cases := []struct {
+		name         string
+		probeOutput  string
+		localVersion string
+		want         string
+	}{
+		{
+			name:         "dump from postgres 17 on older client",
+			probeOutput:  "pg_restore: error: unsupported version (1.16) in file header",
+			localVersion: "16.4",
+			want:         "This backup was created by pg_dump from PostgreSQL 17 or newer, but the server's pg_restore is version 16.4 and cannot read it; run 'x-ui pgclient 17' on the server (or upgrade the postgresql-client package to version 17 or newer), then retry the import",
+		},
+		{
+			name:         "dump from postgres 16 on older client",
+			probeOutput:  "pg_restore: error: unsupported version (1.15) in file header",
+			localVersion: "15.8",
+			want:         "This backup was created by pg_dump from PostgreSQL 16 or newer, but the server's pg_restore is version 15.8 and cannot read it; run 'x-ui pgclient 16' on the server (or upgrade the postgresql-client package to version 16 or newer), then retry the import",
+		},
+		{
+			name:         "archive version newer than any known mapping",
+			probeOutput:  "pg_restore: error: unsupported version (1.17) in file header",
+			localVersion: "17.2",
+			want:         "This backup was created by a newer pg_dump than the server's pg_restore (version 17.2) can read; upgrade the postgresql-client package and retry the import",
+		},
+		{
+			name:         "local version could not be determined",
+			probeOutput:  "pg_restore: error: unsupported version (1.16) in file header",
+			localVersion: "",
+			want:         "This backup was created by pg_dump from PostgreSQL 17 or newer, but the server's pg_restore is version unknown and cannot read it; run 'x-ui pgclient 17' on the server (or upgrade the postgresql-client package to version 17 or newer), then retry the import",
+		},
+		{
+			name:         "unrelated read failure passes through",
+			probeOutput:  "pg_restore: error: could not read from input file: end of file",
+			localVersion: "16.4",
+			want:         "pg_restore cannot read this dump file: pg_restore: error: could not read from input file: end of file",
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			err := pgRestoreReadFailureError(tc.probeOutput, tc.localVersion)
+			if err == nil {
+				t.Fatal("pgRestoreReadFailureError returned nil, want error")
+			}
+			if err.Error() != tc.want {
+				t.Errorf("pgRestoreReadFailureError(%q, %q) = %q, want %q", tc.probeOutput, tc.localVersion, err.Error(), tc.want)
+			}
+		})
+	}
+}
+
+func TestParsePgToolVersion(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"plain", "pg_restore (PostgreSQL) 17.2\n", "17.2"},
+		{"debian packaging suffix", "pg_restore (PostgreSQL) 16.10 (Debian 16.10-1.pgdg120+1)\n", "16.10"},
+		{"three component version", "pg_restore (PostgreSQL) 9.6.24\n", "9.6.24"},
+		{"no version present", "pg_restore malfunction\n", ""},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := parsePgToolVersion(tc.in)
+			if got != tc.want {
+				t.Errorf("parsePgToolVersion(%q) = %q, want %q", tc.in, got, tc.want)
+			}
+		})
+	}
+}

+ 2 - 1
internal/web/translation/ar-EG.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "الاختبار ناجح",
         "testSuccess": "الاختبار ناجح",
         "testFailed": "فشل الاختبار",
         "testFailed": "فشل الاختبار",
         "testError": "فشل اختبار المخرج",
         "testError": "فشل اختبار المخرج",
-        "testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray.",
+        "modeRealDelay": "التأخير الفعلي",
+        "testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray. التأخير الفعلي: الوقت الكامل شاملاً إنشاء الاتصال.",
         "testAll": "اختبار الكل",
         "testAll": "اختبار الكل",
         "httpStatus": "حالة HTTP",
         "httpStatus": "حالة HTTP",
         "breakdownConnect": "اتصال البروكسي",
         "breakdownConnect": "اتصال البروكسي",

+ 2 - 1
internal/web/translation/en-US.json

@@ -1743,7 +1743,8 @@
         "testSuccess": "Test successful",
         "testSuccess": "Test successful",
         "testFailed": "Test failed",
         "testFailed": "Test failed",
         "testError": "Failed to test outbound",
         "testError": "Failed to test outbound",
-        "testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray.",
+        "modeRealDelay": "Real delay",
+        "testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray. Real delay: total time including connection setup.",
         "testAll": "Test all",
         "testAll": "Test all",
         "httpStatus": "HTTP status",
         "httpStatus": "HTTP status",
         "breakdownConnect": "Proxy connect",
         "breakdownConnect": "Proxy connect",

+ 2 - 1
internal/web/translation/es-ES.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "Prueba exitosa",
         "testSuccess": "Prueba exitosa",
         "testFailed": "Prueba fallida",
         "testFailed": "Prueba fallida",
         "testError": "Error al probar la salida",
         "testError": "Error al probar la salida",
-        "testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray.",
+        "modeRealDelay": "Retardo real",
+        "testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray. Retardo real: tiempo total incluyendo el establecimiento de la conexión.",
         "testAll": "Probar todo",
         "testAll": "Probar todo",
         "httpStatus": "Estado HTTP",
         "httpStatus": "Estado HTTP",
         "breakdownConnect": "Conexión al proxy",
         "breakdownConnect": "Conexión al proxy",

+ 2 - 1
internal/web/translation/fa-IR.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "تست موفقیت‌آمیز",
         "testSuccess": "تست موفقیت‌آمیز",
         "testFailed": "تست ناموفق",
         "testFailed": "تست ناموفق",
         "testError": "خطا در تست خروجی",
         "testError": "خطا در تست خروجی",
-        "testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray.",
+        "modeRealDelay": "تأخیر واقعی",
+        "testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray. تأخیر واقعی: کل زمان همراه با برقراری اتصال.",
         "testAll": "تست همه",
         "testAll": "تست همه",
         "httpStatus": "وضعیت HTTP",
         "httpStatus": "وضعیت HTTP",
         "breakdownConnect": "اتصال پروکسی",
         "breakdownConnect": "اتصال پروکسی",

+ 2 - 1
internal/web/translation/id-ID.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "Tes berhasil",
         "testSuccess": "Tes berhasil",
         "testFailed": "Tes gagal",
         "testFailed": "Tes gagal",
         "testError": "Gagal menguji outbound",
         "testError": "Gagal menguji outbound",
-        "testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray.",
+        "modeRealDelay": "Delay nyata",
+        "testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray. Delay nyata: total waktu termasuk pembentukan koneksi.",
         "testAll": "Tes semua",
         "testAll": "Tes semua",
         "httpStatus": "Status HTTP",
         "httpStatus": "Status HTTP",
         "breakdownConnect": "Koneksi proxy",
         "breakdownConnect": "Koneksi proxy",

+ 2 - 1
internal/web/translation/ja-JP.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "テスト成功",
         "testSuccess": "テスト成功",
         "testFailed": "テスト失敗",
         "testFailed": "テスト失敗",
         "testError": "アウトバウンドのテストに失敗しました",
         "testError": "アウトバウンドのテストに失敗しました",
-        "testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。",
+        "modeRealDelay": "実際の遅延",
+        "testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。実際の遅延: 接続確立を含む合計時間。",
         "testAll": "すべてテスト",
         "testAll": "すべてテスト",
         "httpStatus": "HTTPステータス",
         "httpStatus": "HTTPステータス",
         "breakdownConnect": "プロキシ接続",
         "breakdownConnect": "プロキシ接続",

+ 2 - 1
internal/web/translation/pt-BR.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "Teste bem-sucedido",
         "testSuccess": "Teste bem-sucedido",
         "testFailed": "Teste falhou",
         "testFailed": "Teste falhou",
         "testError": "Falha ao testar saída",
         "testError": "Falha ao testar saída",
-        "testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray.",
+        "modeRealDelay": "Latência real",
+        "testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray. Latência real: tempo total incluindo o estabelecimento da conexão.",
         "testAll": "Testar todos",
         "testAll": "Testar todos",
         "httpStatus": "Status HTTP",
         "httpStatus": "Status HTTP",
         "breakdownConnect": "Conexão do proxy",
         "breakdownConnect": "Conexão do proxy",

+ 2 - 1
internal/web/translation/ru-RU.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "Тест успешен",
         "testSuccess": "Тест успешен",
         "testFailed": "Тест не пройден",
         "testFailed": "Тест не пройден",
         "testError": "Не удалось протестировать исходящее подключение",
         "testError": "Не удалось протестировать исходящее подключение",
-        "testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray.",
+        "modeRealDelay": "Реальная задержка",
+        "testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray. Реальная задержка: полное время с установлением соединения.",
         "testAll": "Тестировать все",
         "testAll": "Тестировать все",
         "httpStatus": "HTTP-статус",
         "httpStatus": "HTTP-статус",
         "breakdownConnect": "Подключение к прокси",
         "breakdownConnect": "Подключение к прокси",

+ 2 - 1
internal/web/translation/tr-TR.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "Test başarılı",
         "testSuccess": "Test başarılı",
         "testFailed": "Test başarısız",
         "testFailed": "Test başarısız",
         "testError": "Giden bağlantı test edilemedi",
         "testError": "Giden bağlantı test edilemedi",
-        "testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek.",
+        "modeRealDelay": "Gerçek gecikme",
+        "testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek. Gerçek gecikme: bağlantı kurulumu dahil toplam süre.",
         "testAll": "Tümünü Test Et",
         "testAll": "Tümünü Test Et",
         "httpStatus": "HTTP durumu",
         "httpStatus": "HTTP durumu",
         "breakdownConnect": "Proxy bağlantısı",
         "breakdownConnect": "Proxy bağlantısı",

+ 2 - 1
internal/web/translation/uk-UA.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "Тест успішний",
         "testSuccess": "Тест успішний",
         "testFailed": "Тест не пройдено",
         "testFailed": "Тест не пройдено",
         "testError": "Не вдалося протестувати вихідне з'єднання",
         "testError": "Не вдалося протестувати вихідне з'єднання",
-        "testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray.",
+        "modeRealDelay": "Реальна затримка",
+        "testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray. Реальна затримка: повний час із встановленням з'єднання.",
         "testAll": "Тестувати всі",
         "testAll": "Тестувати всі",
         "httpStatus": "HTTP-статус",
         "httpStatus": "HTTP-статус",
         "breakdownConnect": "Підключення до проксі",
         "breakdownConnect": "Підключення до проксі",

+ 2 - 1
internal/web/translation/vi-VN.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "Kiểm tra thành công",
         "testSuccess": "Kiểm tra thành công",
         "testFailed": "Kiểm tra thất bại",
         "testFailed": "Kiểm tra thất bại",
         "testError": "Không thể kiểm tra đầu ra",
         "testError": "Không thể kiểm tra đầu ra",
-        "testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray.",
+        "modeRealDelay": "Độ trễ thực",
+        "testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray. Độ trễ thực: tổng thời gian gồm cả thiết lập kết nối.",
         "testAll": "Kiểm tra tất cả",
         "testAll": "Kiểm tra tất cả",
         "httpStatus": "Trạng thái HTTP",
         "httpStatus": "Trạng thái HTTP",
         "breakdownConnect": "Kết nối proxy",
         "breakdownConnect": "Kết nối proxy",

+ 2 - 1
internal/web/translation/zh-CN.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "测试成功",
         "testSuccess": "测试成功",
         "testFailed": "测试失败",
         "testFailed": "测试失败",
         "testError": "测试出站失败",
         "testError": "测试出站失败",
-        "testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。",
+        "modeRealDelay": "真实延迟",
+        "testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。真实延迟: 含建立连接的总耗时。",
         "testAll": "全部测试",
         "testAll": "全部测试",
         "httpStatus": "HTTP 状态",
         "httpStatus": "HTTP 状态",
         "breakdownConnect": "代理连接",
         "breakdownConnect": "代理连接",

+ 2 - 1
internal/web/translation/zh-TW.json

@@ -1627,7 +1627,8 @@
         "testSuccess": "測試成功",
         "testSuccess": "測試成功",
         "testFailed": "測試失敗",
         "testFailed": "測試失敗",
         "testError": "測試出站失敗",
         "testError": "測試出站失敗",
-        "testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。",
+        "modeRealDelay": "真實延遲",
+        "testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。真實延遲: 含建立連線的總耗時。",
         "testAll": "全部測試",
         "testAll": "全部測試",
         "httpStatus": "HTTP 狀態",
         "httpStatus": "HTTP 狀態",
         "breakdownConnect": "代理連線",
         "breakdownConnect": "代理連線",

+ 3 - 1
update.sh

@@ -498,7 +498,9 @@ ssl_cert_issue() {
     # get the port number for the standalone server
     # get the port number for the standalone server
     local WebPort=80
     local WebPort=80
     read -rp "Please choose which port to use (default is 80): " WebPort
     read -rp "Please choose which port to use (default is 80): " WebPort
-    if [[ ${WebPort} -gt 65535 || ${WebPort} -lt 1 ]]; then
+    if [[ -z ${WebPort} ]]; then
+        WebPort=80
+    elif [[ ! ${WebPort} =~ ^[1-9][0-9]*$ || ${WebPort} -gt 65535 ]]; then
         echo -e "${yellow}Your input ${WebPort} is invalid, will use default port 80.${plain}"
         echo -e "${yellow}Your input ${WebPort} is invalid, will use default port 80.${plain}"
         WebPort=80
         WebPort=80
     fi
     fi

+ 111 - 1
x-ui.sh

@@ -1838,7 +1838,9 @@ ssl_cert_issue() {
     # get the port number for the standalone server
     # get the port number for the standalone server
     local WebPort=80
     local WebPort=80
     read -rp "Please choose which port to use (default is 80): " WebPort
     read -rp "Please choose which port to use (default is 80): " WebPort
-    if [[ ${WebPort} -gt 65535 || ${WebPort} -lt 1 ]]; then
+    if [[ -z ${WebPort} ]]; then
+        WebPort=80
+    elif [[ ! ${WebPort} =~ ^[1-9][0-9]*$ || ${WebPort} -gt 65535 ]]; then
         LOGE "Your input ${WebPort} is invalid, will use default port 80."
         LOGE "Your input ${WebPort} is invalid, will use default port 80."
         WebPort=80
         WebPort=80
     fi
     fi
@@ -3012,6 +3014,104 @@ pg_ensure_client() {
     command -v pg_dump > /dev/null 2>&1 && command -v pg_restore > /dev/null 2>&1
     command -v pg_dump > /dev/null 2>&1 && command -v pg_restore > /dev/null 2>&1
 }
 }
 
 
+# Prints the major version of the installed pg_restore, or nothing when absent.
+pg_client_major() {
+    command -v pg_restore > /dev/null 2>&1 || return 1
+    pg_restore --version 2> /dev/null | grep -oE '[0-9]+' | head -n 1
+}
+
+# Installs or upgrades the PostgreSQL client tools (pg_dump/pg_restore) so their
+# major version is at least $1 (e.g. 17); with no argument any installed version
+# is accepted. Falls back to the official PostgreSQL package repository when the
+# distribution one is too old. Restoring a panel backup made by a newer pg_dump
+# needs this:   x-ui pgclient <major>
+pg_upgrade_client() {
+    local want="$1" have
+    if [[ -n "$want" && ! "$want" =~ ^[0-9]+$ ]]; then
+        LOGE "Invalid PostgreSQL major version '${want}' (expected a number like 17)."
+        return 1
+    fi
+    have=$(pg_client_major)
+    if [[ -n "$have" ]]; then
+        if [[ -z "$want" || "$have" -ge "$want" ]]; then
+            LOGI "PostgreSQL client tools are already installed (version ${have})."
+            return 0
+        fi
+        LOGI "Installed PostgreSQL client tools are version ${have}; version ${want} or newer is required."
+    fi
+    if [[ "${running_in_docker}" == "true" ]]; then
+        LOGI "Note: packages installed inside the container are lost when the container is recreated."
+    fi
+    case "${release}" in
+        ubuntu | debian | armbian)
+            apt-get update >&2 || return 1
+            if [[ -z "$want" ]]; then
+                apt-get install -y -q postgresql-client >&2 || return 1
+            elif ! apt-get install -y -q "postgresql-client-${want}" >&2; then
+                LOGI "postgresql-client-${want} is not in the distribution repositories; adding the official PostgreSQL apt repository..."
+                apt-get install -y -q postgresql-common ca-certificates >&2 || return 1
+                /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y >&2 || return 1
+                apt-get install -y -q "postgresql-client-${want}" >&2 || return 1
+            fi
+            ;;
+        fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos)
+            local pkg_mgr="dnf"
+            command -v dnf > /dev/null 2>&1 || pkg_mgr="yum"
+            if [[ -z "$want" ]]; then
+                "$pkg_mgr" install -y -q postgresql >&2 || return 1
+            elif ! "$pkg_mgr" install -y -q "postgresql${want}" >&2; then
+                local elver
+                elver=$(rpm -E %rhel 2> /dev/null)
+                if [[ ! "$elver" =~ ^[0-9]+$ ]]; then
+                    LOGE "Could not determine the Enterprise Linux release; install the PostgreSQL ${want} client tools manually."
+                    return 1
+                fi
+                LOGI "postgresql${want} is not in the enabled repositories; adding the official PostgreSQL yum repository..."
+                "$pkg_mgr" install -y "https://download.postgresql.org/pub/repos/yum/reporpms/EL-${elver}-$(uname -m)/pgdg-redhat-repo-latest.noarch.rpm" >&2 || return 1
+                if [[ "$pkg_mgr" == "dnf" ]]; then
+                    dnf -qy module disable postgresql >&2 || true
+                fi
+                "$pkg_mgr" install -y -q "postgresql${want}" >&2 || return 1
+            fi
+            ;;
+        arch | manjaro | parch)
+            pacman -Sy --noconfirm postgresql >&2 || return 1
+            ;;
+        opensuse-tumbleweed | opensuse-leap)
+            if [[ -z "$want" ]] || ! zypper -q install -y "postgresql${want}" >&2; then
+                zypper -q install -y postgresql >&2 || return 1
+            fi
+            ;;
+        alpine)
+            if [[ -z "$want" ]] || ! apk add --no-cache "postgresql${want}-client" >&2; then
+                apk add --no-cache postgresql-client >&2 || return 1
+            fi
+            ;;
+        *)
+            LOGE "Unsupported OS '${release}'; install the PostgreSQL client tools manually."
+            return 1
+            ;;
+    esac
+    hash -r 2> /dev/null
+    have=$(pg_client_major)
+    if [[ -n "$want" && ( -z "$have" || "$have" -lt "$want" ) && -x "/usr/pgsql-${want}/bin/pg_restore" ]]; then
+        ln -sf "/usr/pgsql-${want}/bin/pg_dump" /usr/local/bin/pg_dump
+        ln -sf "/usr/pgsql-${want}/bin/pg_restore" /usr/local/bin/pg_restore
+        hash -r 2> /dev/null
+        have=$(pg_client_major)
+    fi
+    if [[ -z "$have" ]]; then
+        LOGE "pg_dump/pg_restore are still unavailable after installation."
+        return 1
+    fi
+    if [[ -n "$want" && "$have" -lt "$want" ]]; then
+        LOGE "PostgreSQL client tools are version ${have} after installation but ${want} or newer is required; install them manually."
+        return 1
+    fi
+    LOGI "PostgreSQL client tools are ready (version ${have})."
+    return 0
+}
+
 # Writes XUI_DB_TYPE/XUI_DB_DSN into the service env file, preserving other entries.
 # Writes XUI_DB_TYPE/XUI_DB_DSN into the service env file, preserving other entries.
 pg_write_env() {
 pg_write_env() {
     local dsn="$1" envfile
     local dsn="$1" envfile
@@ -3122,6 +3222,7 @@ postgresql_menu() {
     echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
     echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
     echo -e "${green}\t8.${plain} View PostgreSQL Log"
     echo -e "${green}\t8.${plain} View PostgreSQL Log"
     echo -e "${green}\t9.${plain} Convert SQLite ${green}.db <-> .dump${plain}"
     echo -e "${green}\t9.${plain} Convert SQLite ${green}.db <-> .dump${plain}"
+    echo -e "${green}\t10.${plain} Install/Upgrade client tools (pg_dump/pg_restore)"
     echo -e "${green}\t0.${plain} Back to Main Menu"
     echo -e "${green}\t0.${plain} Back to Main Menu"
     read -rp "Choose an option: " choice
     read -rp "Choose an option: " choice
     case "$choice" in
     case "$choice" in
@@ -3164,6 +3265,11 @@ postgresql_menu() {
             migrate_db_prompt
             migrate_db_prompt
             postgresql_menu
             postgresql_menu
             ;;
             ;;
+        10)
+            read -rp "Required PostgreSQL major version (empty = any): " pg_client_ver
+            pg_upgrade_client "$pg_client_ver"
+            postgresql_menu
+            ;;
         *)
         *)
             echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
             echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
             postgresql_menu
             postgresql_menu
@@ -3283,6 +3389,7 @@ show_usage() {
 │  ${blue}x-ui update-dev${plain}            - Update to Dev channel (latest)   │
 │  ${blue}x-ui update-dev${plain}            - Update to Dev channel (latest)   │
 │  ${blue}x-ui update-all-geofiles${plain}   - Update all geo files             │
 │  ${blue}x-ui update-all-geofiles${plain}   - Update all geo files             │
 │  ${blue}x-ui migrateDB [file]${plain}      - Convert .db <-> .dump (SQLite)   │
 │  ${blue}x-ui migrateDB [file]${plain}      - Convert .db <-> .dump (SQLite)   │
+│  ${blue}x-ui pgclient [ver]${plain}        - Upgrade pg_dump/pg_restore tools │
 │  ${blue}x-ui legacy${plain}                - Legacy version                   │
 │  ${blue}x-ui legacy${plain}                - Legacy version                   │
 │  ${blue}x-ui install${plain}               - Install                          │
 │  ${blue}x-ui install${plain}               - Install                          │
 │  ${blue}x-ui uninstall${plain}             - Uninstall                        │
 │  ${blue}x-ui uninstall${plain}             - Uninstall                        │
@@ -3486,6 +3593,9 @@ if [[ $# > 0 ]]; then
         "migrateDB")
         "migrateDB")
             migrate_db "$2" "$3"
             migrate_db "$2" "$3"
             ;;
             ;;
+        "pgclient")
+            pg_upgrade_client "$2"
+            ;;
         *) show_usage ;;
         *) show_usage ;;
     esac
     esac
 else
 else