8 Commits 3af45c1462 ... 2928b52b04

Author SHA1 Message Date
  MHSanaei 2928b52b04 feat(tgbot): add Flow picker when creating a VLESS client 1 day ago
  MHSanaei 07cdb82027 fix(inbounds): don't delete remote inbound when toggling enable 1 day ago
  MHSanaei f00f82b392 fix(outbound): probe UDP-based outbounds over UDP instead of TCP 2 days ago
  MHSanaei 5a1019534f refactor(inbounds): tighten advanced JSON helpers and fix dark-mode subtitles 2 days ago
  Abdalrahman 78f1719c6d fix: prevent online clients from randomly disappearing from panel UI (#4387) 2 days ago
  MHSanaei 5cf8a08540 fix: disable balancer fallbackTag for random / roundRobin strategies 2 days ago
  MHSanaei 79a9be7b22 fix: split locale chunks by removing eager i18n glob 2 days ago
  Abdalrahman 19d50bd16c fix: add i18n translations for Allow private address node option across all locales (#4386) 2 days ago

+ 4 - 2
frontend/src/entries/api-docs.js

@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
 
 
 import { setupAxios } from '@/api/axios-init.js';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
 import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
 
 
@@ -16,4 +16,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/inbounds.js

@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
 
 
 import { setupAxios } from '@/api/axios-init.js';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
 import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
 
 
@@ -16,4 +16,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/index.js

@@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
 // Importing useTheme triggers the boot side-effect that applies the
 // Importing useTheme triggers the boot side-effect that applies the
 // stored theme to <body>/<html> before Vue mounts.
 // stored theme to <body>/<html> before Vue mounts.
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import IndexPage from '@/pages/index/IndexPage.vue';
 import IndexPage from '@/pages/index/IndexPage.vue';
 
 
@@ -18,4 +18,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(IndexPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(IndexPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 4
frontend/src/entries/login.js

@@ -6,18 +6,18 @@ import { setupAxios } from '@/api/axios-init.js';
 // Importing this module triggers the boot side-effect that applies the
 // Importing this module triggers the boot side-effect that applies the
 // stored theme to <body>/<html> before Vue renders anything.
 // stored theme to <body>/<html> before Vue renders anything.
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import LoginPage from '@/pages/login/LoginPage.vue';
 import LoginPage from '@/pages/login/LoginPage.vue';
 
 
 setupAxios();
 setupAxios();
 applyDocumentTitle();
 applyDocumentTitle();
 
 
-// Toasts attach to a #message div the page provides — keeps theme
-// styling in sync with the rest of the panel.
 const messageContainer = document.getElementById('message');
 const messageContainer = document.getElementById('message');
 if (messageContainer) {
 if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(LoginPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(LoginPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/nodes.js

@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
 
 
 import { setupAxios } from '@/api/axios-init.js';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import NodesPage from '@/pages/nodes/NodesPage.vue';
 import NodesPage from '@/pages/nodes/NodesPage.vue';
 
 
@@ -16,4 +16,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(NodesPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(NodesPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/settings.js

@@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
 // Importing useTheme triggers the boot side-effect that applies the
 // Importing useTheme triggers the boot side-effect that applies the
 // stored theme to <body>/<html> before Vue mounts.
 // stored theme to <body>/<html> before Vue mounts.
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import SettingsPage from '@/pages/settings/SettingsPage.vue';
 import SettingsPage from '@/pages/settings/SettingsPage.vue';
 
 
@@ -18,4 +18,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/subpage.js

@@ -7,7 +7,7 @@ import 'ant-design-vue/dist/reset.css';
 // with the parsed traffic/quota/expiry view-model and the rendered
 // with the parsed traffic/quota/expiry view-model and the rendered
 // share links — the SPA reads those at mount.
 // share links — the SPA reads those at mount.
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import SubPage from '@/pages/sub/SubPage.vue';
 import SubPage from '@/pages/sub/SubPage.vue';
 
 
 const messageContainer = document.getElementById('message');
 const messageContainer = document.getElementById('message');
@@ -15,4 +15,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(SubPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(SubPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/xray.js

@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
 
 
 import { setupAxios } from '@/api/axios-init.js';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import XrayPage from '@/pages/xray/XrayPage.vue';
 import XrayPage from '@/pages/xray/XrayPage.vue';
 
 
@@ -16,4 +16,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(XrayPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(XrayPage).use(Antd).use(i18n).mount('#app');
+});

+ 19 - 58
frontend/src/i18n/index.js

@@ -1,93 +1,54 @@
-// vue-i18n setup. Locale files live in web/translation/*.json — the same
-// directory the Go binary embeds, so SPA + Telegram bot + subscription
-// page all read from a single source.
-//
-// Usage in a component:
-//   import { useI18n } from 'vue-i18n';
-//   const { t } = useI18n();
-//   ...
-//   <span>{{ t('pages.inbounds.email') }}</span>
-//
-// Or via the global helper exposed on the app:
-//   <span>{{ $t('pages.inbounds.email') }}</span>
-//
-// The locale follows the `lang` cookie that LanguageManager already
-// reads/writes — switching language anywhere in the app continues to
-// trigger a full page reload (matches legacy ergonomics), so we don't
-// need a runtime locale switcher here.
-
 import { createI18n } from 'vue-i18n';
 import { createI18n } from 'vue-i18n';
 
 
 import { LanguageManager } from '@/utils';
 import { LanguageManager } from '@/utils';
+import enUS from '../../../web/translation/en-US.json';
 
 
-// Lazy-loaded locales — Vite splits each one into its own chunk. We
-// eager-load only the active language plus the en-US fallback so the
-// initial page payload stays small (the inbounds bundle was sitting
-// at ~700kB gzipped with all 13 locales eager; now ~480kB).
-//
-// LanguageManager.setLanguage() does a full reload on change, so
-// "lazy" here effectively means "load only what this page needs for
-// its lifetime."
 const FALLBACK = 'en-US';
 const FALLBACK = 'en-US';
-const lazyModules = import.meta.glob('../../../web/translation/*.json');
-const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
+const lazyModules = import.meta.glob([
+  '../../../web/translation/*.json',
+  '!../../../web/translation/en-US.json',
+]);
 
 
 function moduleKeyFor(code) {
 function moduleKeyFor(code) {
   return `../../../web/translation/${code}.json`;
   return `../../../web/translation/${code}.json`;
 }
 }
 
 
-// Resolve the active locale via LanguageManager so the cookie set on
-// the legacy panel keeps working after a user upgrades. Falls back
-// to en-US when the cookie names a language we don't have.
 let active = LanguageManager.getLanguage();
 let active = LanguageManager.getLanguage();
-if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
+if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
   active = FALLBACK;
   active = FALLBACK;
 }
 }
 
 
-const messages = {};
-// Eagerly include the active locale + the fallback (when distinct)
-// so the very first render has strings ready. Vite still emits these
-// as their own chunks so the user pays for at most two locales.
-for (const code of new Set([active, FALLBACK])) {
-  const mod = eagerModules[moduleKeyFor(code)];
-  if (mod) messages[code] = mod.default || mod;
-}
-
 export const i18n = createI18n({
 export const i18n = createI18n({
   legacy: false,
   legacy: false,
-  // `composition` mode (legacy: false) so `useI18n()` works in
-  // <script setup> blocks.
   globalInjection: true,
   globalInjection: true,
   locale: active,
   locale: active,
   fallbackLocale: FALLBACK,
   fallbackLocale: FALLBACK,
-  // Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}})
-  // so vue-i18n's default `.`-delimited lookups walk straight into it.
-  messages,
-  // The Go side sometimes interpolates `#variable#` into translated
-  // strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
-  // expects `{var}` — disable warnings about strings that look like
-  // they don't use the new syntax.
+  messages: { [FALLBACK]: enUS },
   warnHtmlMessage: false,
   warnHtmlMessage: false,
   missingWarn: false,
   missingWarn: false,
   fallbackWarn: false,
   fallbackWarn: false,
 });
 });
 
 
-// Convenience export for non-component contexts (HTTP error toasts,
-// stores, etc.) that need to look up a translation outside a setup
-// scope.
 export function t(key, params) {
 export function t(key, params) {
   return i18n.global.t(key, params || {});
   return i18n.global.t(key, params || {});
 }
 }
 
 
-// loadLocale fetches a locale module on demand and registers it with
-// vue-i18n. Pages that switch language at runtime (rather than via
-// LanguageManager's reload) can call this to swap strings live.
 export async function loadLocale(code) {
 export async function loadLocale(code) {
-  const key = moduleKeyFor(code);
-  const loader = lazyModules[key];
+  if (code === FALLBACK) {
+    i18n.global.locale.value = FALLBACK;
+    return true;
+  }
+  const loader = lazyModules[moduleKeyFor(code)];
   if (!loader) return false;
   if (!loader) return false;
   const mod = await loader();
   const mod = await loader();
   i18n.global.setLocaleMessage(code, mod.default || mod);
   i18n.global.setLocaleMessage(code, mod.default || mod);
   i18n.global.locale.value = code;
   i18n.global.locale.value = code;
   return true;
   return true;
 }
 }
+
+export async function readyI18n() {
+  if (active !== FALLBACK) {
+    await loadLocale(active);
+  }
+  return i18n;
+}

+ 144 - 267
frontend/src/pages/inbounds/InboundFormModal.vue

@@ -226,20 +226,7 @@ function freshDbForm() {
 
 
 function primeAdvancedJson() {
 function primeAdvancedJson() {
   if (!inbound.value) return;
   if (!inbound.value) return;
-  // Only set stream text for protocols that support it
-  if (canEnableStream.value) {
-    try {
-      advancedStreamText.value = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
-    } catch (_e) { /* keep prior text */ }
-  } else {
-    advancedStreamText.value = '{}';
-  }
-  try {
-    advancedSniffingText.value = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
-  } catch (_e) { /* keep prior text */ }
-  try {
-    advancedSettingsText.value = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
-  } catch (_e) { /* keep prior text */ }
+  ['stream', 'sniffing', 'settings'].forEach(stampAdvancedTextFor);
 }
 }
 
 
 watch(() => props.open, (next) => {
 watch(() => props.open, (next) => {
@@ -258,34 +245,22 @@ watch(() => props.open, (next) => {
 
 
 function applyAdvancedJsonToBasic() {
 function applyAdvancedJsonToBasic() {
   if (!inbound.value) return true;
   if (!inbound.value) return true;
-  let parsedSettings;
-  let parsedStream;
-  let parsedSniffing;
-  try {
-    parsedSettings = advancedSettingsText.value.trim()
-      ? JSON.parse(advancedSettingsText.value)
-      : inbound.value.settings?.toJson?.();
-  } catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; }
-  try {
-    parsedStream = advancedStreamText.value.trim()
-      ? JSON.parse(advancedStreamText.value)
-      : inbound.value.stream?.toJson?.();
-  } catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
+  let settings; let streamSettings; let sniffing;
   try {
   try {
-    parsedSniffing = advancedSniffingText.value.trim()
-      ? JSON.parse(advancedSniffingText.value)
-      : inbound.value.sniffing?.toJson?.();
-  } catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
+    settings = parseAdvancedSliceWithLabel(advancedSettingsText.value, settingsFallback(), 'Settings');
+    streamSettings = parseAdvancedSliceWithLabel(advancedStreamText.value, streamFallback(), 'Stream');
+    sniffing = parseAdvancedSliceWithLabel(advancedSniffingText.value, sniffingFallback(), 'Sniffing');
+  } catch (_e) { return false; }
 
 
   try {
   try {
     inbound.value = Inbound.fromJson({
     inbound.value = Inbound.fromJson({
       port: inbound.value.port,
       port: inbound.value.port,
       listen: inbound.value.listen,
       listen: inbound.value.listen,
       protocol: inbound.value.protocol,
       protocol: inbound.value.protocol,
-      settings: parsedSettings,
-      streamSettings: parsedStream,
+      settings,
+      streamSettings,
       tag: inbound.value.tag,
       tag: inbound.value.tag,
-      sniffing: parsedSniffing,
+      sniffing,
       clientStats: inbound.value.clientStats,
       clientStats: inbound.value.clientStats,
     });
     });
   } catch (e) {
   } catch (e) {
@@ -350,37 +325,102 @@ function unwrapWrappedObject(parsed, key) {
   return parsed;
   return parsed;
 }
 }
 
 
+const settingsFallback = () => inbound.value?.settings?.toJson?.() || {};
+const sniffingFallback = () => inbound.value?.sniffing?.toJson?.() || {};
+const streamFallback = () => inbound.value?.stream?.toJson?.() || {};
+
+const advancedTextRefs = {
+  stream: advancedStreamText,
+  sniffing: advancedSniffingText,
+  settings: advancedSettingsText,
+};
+
+function stampAdvancedTextFor(slice) {
+  const textRef = advancedTextRefs[slice];
+  if (!textRef) return;
+  if (slice === 'stream' && !canEnableStream.value) {
+    textRef.value = '{}';
+    return;
+  }
+  const obj = inbound.value?.[slice];
+  if (!obj) return;
+  try {
+    textRef.value = JSON.stringify(JSON.parse(obj.toString()), null, 2);
+  } catch (_e) { /* keep prior text */ }
+}
+
+function parseAdvancedSliceWithLabel(rawText, fallback, label) {
+  try {
+    return parseAdvancedSliceOrFallback(rawText, fallback);
+  } catch (e) {
+    message.error(`${label} JSON invalid: ${e.message}`);
+    throw e;
+  }
+}
+
+function compactAdvancedJson(raw, fallback, label) {
+  try {
+    return JSON.stringify(JSON.parse(raw || fallback));
+  } catch (e) {
+    message.error(`${label} JSON invalid: ${e.message}`);
+    throw e;
+  }
+}
+
+async function withSaving(fn) {
+  saving.value = true;
+  try { return await fn(); } finally { saving.value = false; }
+}
+
+function makeWrappedAdvancedConfig({ key, textRef, getFallback, label }) {
+  const invalid = `${label} JSON invalid`;
+  return computed({
+    get: () => {
+      if (!inbound.value) return '';
+      try {
+        const value = parseAdvancedSliceOrFallback(textRef.value, getFallback());
+        return JSON.stringify({ [key]: value }, null, 2);
+      } catch (_e) {
+        return '';
+      }
+    },
+    set: (next) => {
+      let parsed;
+      try {
+        parsed = JSON.parse(next);
+      } catch (e) {
+        message.error(`${invalid}: ${e.message}`);
+        return;
+      }
+      const unwrapped = unwrapWrappedObject(parsed, key);
+      if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
+        message.error(`${label} JSON must be an object or { ${key}: { ... } }.`);
+        return;
+      }
+      try {
+        textRef.value = JSON.stringify(unwrapped, null, 2);
+      } catch (e) {
+        message.error(`${invalid}: ${e.message}`);
+      }
+    },
+  });
+}
+
 const advancedAllConfig = computed({
 const advancedAllConfig = computed({
   get: () => {
   get: () => {
     if (!inbound.value) return '';
     if (!inbound.value) return '';
     try {
     try {
-      const settings = parseAdvancedSliceOrFallback(
-        advancedSettingsText.value,
-        inbound.value.settings?.toJson?.() || {},
-      );
-      const streamSettings = parseAdvancedSliceOrFallback(
-        advancedStreamText.value,
-        inbound.value.stream?.toJson?.() || {},
-      );
-      const sniffing = parseAdvancedSliceOrFallback(
-        advancedSniffingText.value,
-        inbound.value.sniffing?.toJson?.() || {},
-      );
-
       const result = {
       const result = {
         listen: inbound.value.listen,
         listen: inbound.value.listen,
         port: inbound.value.port,
         port: inbound.value.port,
         protocol: inbound.value.protocol,
         protocol: inbound.value.protocol,
-        settings,
-        sniffing,
+        settings: parseAdvancedSliceOrFallback(advancedSettingsText.value, settingsFallback()),
+        sniffing: parseAdvancedSliceOrFallback(advancedSniffingText.value, sniffingFallback()),
         tag: inbound.value.tag,
         tag: inbound.value.tag,
       };
       };
-
-      // Only include streamSettings for protocols that support it
       if (canEnableStream.value) {
       if (canEnableStream.value) {
-        result.streamSettings = streamSettings;
+        result.streamSettings = parseAdvancedSliceOrFallback(advancedStreamText.value, streamFallback());
       }
       }
-
       return JSON.stringify(result, null, 2);
       return JSON.stringify(result, null, 2);
     } catch (_e) {
     } catch (_e) {
       return '';
       return '';
@@ -400,147 +440,47 @@ const advancedAllConfig = computed({
     }
     }
 
 
     try {
     try {
-      if (typeof parsed.listen === 'string') {
-        inbound.value.listen = parsed.listen;
-      }
+      if (typeof parsed.listen === 'string') inbound.value.listen = parsed.listen;
       if (parsed.port !== undefined) {
       if (parsed.port !== undefined) {
-        const parsedPort = Number(parsed.port);
-        if (!Number.isNaN(parsedPort) && Number.isFinite(parsedPort)) {
-          inbound.value.port = parsedPort;
-        }
+        const port = Number(parsed.port);
+        if (Number.isFinite(port)) inbound.value.port = port;
       }
       }
       if (typeof parsed.protocol === 'string' && PROTOCOLS.includes(parsed.protocol)) {
       if (typeof parsed.protocol === 'string' && PROTOCOLS.includes(parsed.protocol)) {
         inbound.value.protocol = parsed.protocol;
         inbound.value.protocol = parsed.protocol;
       }
       }
-      if (typeof parsed.tag === 'string') {
-        inbound.value.tag = parsed.tag;
-      }
-
-      const existingSettings = parseAdvancedSliceOrFallback(
-        advancedSettingsText.value,
-        inbound.value?.settings?.toJson?.() || {},
-      );
-      const settings = parsed.settings ?? existingSettings;
-      const sniffing = parsed.sniffing ?? (inbound.value?.sniffing?.toJson?.() || {});
-      advancedSettingsText.value = JSON.stringify(settings, null, 2);
-      advancedSniffingText.value = JSON.stringify(sniffing, null, 2);
-
-      // Only update stream settings if protocol supports it
-      if (canEnableStream.value) {
-        const streamSettings = parsed.streamSettings ?? (inbound.value?.stream?.toJson?.() || {});
-        advancedStreamText.value = JSON.stringify(streamSettings, null, 2);
-      } else {
-        advancedStreamText.value = '{}';
-      }
+      if (typeof parsed.tag === 'string') inbound.value.tag = parsed.tag;
+
+      const existingSettings = parseAdvancedSliceOrFallback(advancedSettingsText.value, settingsFallback());
+      advancedSettingsText.value = JSON.stringify(parsed.settings ?? existingSettings, null, 2);
+      advancedSniffingText.value = JSON.stringify(parsed.sniffing ?? sniffingFallback(), null, 2);
+      advancedStreamText.value = canEnableStream.value
+        ? JSON.stringify(parsed.streamSettings ?? streamFallback(), null, 2)
+        : '{}';
     } catch (e) {
     } catch (e) {
       message.error(`All JSON invalid: ${e.message}`);
       message.error(`All JSON invalid: ${e.message}`);
     }
     }
   },
   },
 });
 });
 
 
-const advancedSettingsConfig = computed({
-  get: () => {
-    if (!inbound.value) return '';
-    try {
-      const settings = parseAdvancedSliceOrFallback(
-        advancedSettingsText.value,
-        inbound.value.settings?.toJson?.() || {},
-      );
-      return JSON.stringify({
-        settings,
-      }, null, 2);
-    } catch (_e) {
-      return '';
-    }
-  },
-  set: (next) => {
-    let parsed;
-    try {
-      parsed = JSON.parse(next);
-    } catch (e) {
-      message.error(`Settings JSON invalid: ${e.message}`);
-      return;
-    }
-    const unwrapped = unwrapWrappedObject(parsed, 'settings');
-    if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
-      message.error('Settings JSON must be an object or { settings: { ... } }.');
-      return;
-    }
-
-    try {
-      advancedSettingsText.value = JSON.stringify(unwrapped, null, 2);
-    } catch (e) {
-      message.error(`Settings JSON invalid: ${e.message}`);
-    }
-  },
+const advancedSettingsConfig = makeWrappedAdvancedConfig({
+  key: 'settings',
+  textRef: advancedSettingsText,
+  getFallback: settingsFallback,
+  label: 'Settings',
 });
 });
 
 
-const advancedSniffingConfig = computed({
-  get: () => {
-    if (!inbound.value) return '';
-    try {
-      const sniffing = parseAdvancedSliceOrFallback(
-        advancedSniffingText.value,
-        inbound.value.sniffing?.toJson?.() || {},
-      );
-      return JSON.stringify({ sniffing }, null, 2);
-    } catch (_e) {
-      return '';
-    }
-  },
-  set: (next) => {
-    let parsed;
-    try {
-      parsed = JSON.parse(next);
-    } catch (e) {
-      message.error(`Sniffing JSON invalid: ${e.message}`);
-      return;
-    }
-    const unwrapped = unwrapWrappedObject(parsed, 'sniffing');
-    if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
-      message.error('Sniffing JSON must be an object or { sniffing: { ... } }.');
-      return;
-    }
-    try {
-      advancedSniffingText.value = JSON.stringify(unwrapped, null, 2);
-    } catch (e) {
-      message.error(`Sniffing JSON invalid: ${e.message}`);
-    }
-  },
+const advancedSniffingConfig = makeWrappedAdvancedConfig({
+  key: 'sniffing',
+  textRef: advancedSniffingText,
+  getFallback: sniffingFallback,
+  label: 'Sniffing',
 });
 });
 
 
-const advancedStreamConfig = computed({
-  get: () => {
-    if (!inbound.value) return '';
-    try {
-      const streamSettings = parseAdvancedSliceOrFallback(
-        advancedStreamText.value,
-        inbound.value.stream?.toJson?.() || {},
-      );
-      return JSON.stringify({ streamSettings }, null, 2);
-    } catch (_e) {
-      return '';
-    }
-  },
-  set: (next) => {
-    let parsed;
-    try {
-      parsed = JSON.parse(next);
-    } catch (e) {
-      message.error(`Stream JSON invalid: ${e.message}`);
-      return;
-    }
-    const unwrapped = unwrapWrappedObject(parsed, 'streamSettings');
-    if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
-      message.error('Stream JSON must be an object or { streamSettings: { ... } }.');
-      return;
-    }
-    try {
-      advancedStreamText.value = JSON.stringify(unwrapped, null, 2);
-    } catch (e) {
-      message.error(`Stream JSON invalid: ${e.message}`);
-    }
-  },
+const advancedStreamConfig = makeWrappedAdvancedConfig({
+  key: 'streamSettings',
+  textRef: advancedStreamText,
+  getFallback: streamFallback,
+  label: 'Stream',
 });
 });
 
 
 // === Random helpers wired to the form's sync icons ==================
 // === Random helpers wired to the form's sync icons ==================
@@ -575,16 +515,13 @@ function regenInboundWg() {
 
 
 // === Reality keygen via existing API =================================
 // === Reality keygen via existing API =================================
 async function genRealityKeypair() {
 async function genRealityKeypair() {
-  saving.value = true;
-  try {
+  await withSaving(async () => {
     const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
     const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
     if (msg?.success) {
     if (msg?.success) {
       inbound.value.stream.reality.privateKey = msg.obj.privateKey;
       inbound.value.stream.reality.privateKey = msg.obj.privateKey;
       inbound.value.stream.reality.settings.publicKey = msg.obj.publicKey;
       inbound.value.stream.reality.settings.publicKey = msg.obj.publicKey;
     }
     }
-  } finally {
-    saving.value = false;
-  }
+  });
 }
 }
 
 
 function clearRealityKeypair() {
 function clearRealityKeypair() {
@@ -594,16 +531,13 @@ function clearRealityKeypair() {
 }
 }
 
 
 async function genMldsa65() {
 async function genMldsa65() {
-  saving.value = true;
-  try {
+  await withSaving(async () => {
     const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
     const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
     if (msg?.success) {
     if (msg?.success) {
       inbound.value.stream.reality.mldsa65Seed = msg.obj.seed;
       inbound.value.stream.reality.mldsa65Seed = msg.obj.seed;
       inbound.value.stream.reality.settings.mldsa65Verify = msg.obj.verify;
       inbound.value.stream.reality.settings.mldsa65Verify = msg.obj.verify;
     }
     }
-  } finally {
-    saving.value = false;
-  }
+  });
 }
 }
 
 
 function clearMldsa65() {
 function clearMldsa65() {
@@ -627,8 +561,7 @@ function randomizeShortIds() {
 // === ECH cert helpers ================================================
 // === ECH cert helpers ================================================
 async function getNewEchCert() {
 async function getNewEchCert() {
   if (!inbound.value?.stream?.tls) return;
   if (!inbound.value?.stream?.tls) return;
-  saving.value = true;
-  try {
+  await withSaving(async () => {
     const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', {
     const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', {
       sni: inbound.value.stream.tls.sni,
       sni: inbound.value.stream.tls.sni,
     });
     });
@@ -636,9 +569,7 @@ async function getNewEchCert() {
       inbound.value.stream.tls.echServerKeys = msg.obj.echServerKeys;
       inbound.value.stream.tls.echServerKeys = msg.obj.echServerKeys;
       inbound.value.stream.tls.settings.echConfigList = msg.obj.echConfigList;
       inbound.value.stream.tls.settings.echConfigList = msg.obj.echConfigList;
     }
     }
-  } finally {
-    saving.value = false;
-  }
+  });
 }
 }
 
 
 function clearEchCert() {
 function clearEchCert() {
@@ -682,17 +613,14 @@ function matchesVlessAuth(block, authId) {
 
 
 async function getNewVlessEnc(authId) {
 async function getNewVlessEnc(authId) {
   if (!authId || !inbound.value?.settings) return;
   if (!authId || !inbound.value?.settings) return;
-  saving.value = true;
-  try {
+  await withSaving(async () => {
     const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
     const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
     if (!msg?.success) return;
     if (!msg?.success) return;
     const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
     const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
     if (!block) return;
     if (!block) return;
     inbound.value.settings.decryption = block.decryption;
     inbound.value.settings.decryption = block.decryption;
     inbound.value.settings.encryption = block.encryption;
     inbound.value.settings.encryption = block.encryption;
-  } finally {
-    saving.value = false;
-  }
+  });
 }
 }
 
 
 function clearVlessEnc() {
 function clearVlessEnc() {
@@ -737,24 +665,16 @@ async function submit() {
   if (!inbound.value || !dbForm.value) return;
   if (!inbound.value || !dbForm.value) return;
   saving.value = true;
   saving.value = true;
   try {
   try {
-    // Sniffing tab is structured; stream stays JSON for unsupported
-    // transports — both go to wire as serialized JSON.
-    let streamSettings;
-    let sniffing;
-    let settings;
+    let streamSettings; let sniffing; let settings;
     try {
     try {
       streamSettings = canEnableStream.value
       streamSettings = canEnableStream.value
-        ? JSON.stringify(JSON.parse(advancedStreamText.value))
+        ? compactAdvancedJson(advancedStreamText.value, '', 'Stream')
         : (inbound.value.stream?.sockopt
         : (inbound.value.stream?.sockopt
           ? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() })
           ? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() })
           : '');
           : '');
-    } catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return; }
-    try {
-      sniffing = JSON.stringify(JSON.parse(advancedSniffingText.value || inbound.value.sniffing.toString()));
-    } catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return; }
-    try {
-      settings = JSON.stringify(JSON.parse(advancedSettingsText.value || inbound.value.settings.toString()));
-    } catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return; }
+      sniffing = compactAdvancedJson(advancedSniffingText.value, inbound.value.sniffing.toString(), 'Sniffing');
+      settings = compactAdvancedJson(advancedSettingsText.value, inbound.value.settings.toString(), 'Settings');
+    } catch (_e) { return; }
 
 
     // The structured form mutates `inbound.stream` directly when the
     // The structured form mutates `inbound.stream` directly when the
     // user edits TCP/WS/gRPC/HTTPUpgrade fields, but if they touched
     // user edits TCP/WS/gRPC/HTTPUpgrade fields, but if they touched
@@ -810,51 +730,15 @@ const okText = computed(() =>
 
 
 // Whenever the structured form mutates stream / sniffing / settings,
 // Whenever the structured form mutates stream / sniffing / settings,
 // refresh the matching slice of the Advanced JSON tab so the user
 // refresh the matching slice of the Advanced JSON tab so the user
-// always sees the live state — flipping a switch in Sniffing or
-// editing encryption in Protocol now reflects in Advanced.
-watch(
-  () => inbound.value && JSON.stringify(inbound.value.stream?.toJson?.() || {}),
-  () => {
-    if (!inbound.value?.stream) return;
-    // Only update stream text for protocols that support it
-    if (!canEnableStream.value) {
-      advancedStreamText.value = '{}';
-      return;
-    }
-    try {
-      advancedStreamText.value = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
-    } catch (_e) { /* leave as is */ }
-  },
-);
-watch(
-  () => inbound.value && JSON.stringify(inbound.value.sniffing?.toJson?.() || {}),
-  () => {
-    if (!inbound.value?.sniffing) return;
-    try {
-      advancedSniffingText.value = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
-    } catch (_e) { /* leave as is */ }
-  },
-);
-watch(
-  () => inbound.value && JSON.stringify(inbound.value.settings?.toJson?.() || {}),
-  () => {
-    if (!inbound.value?.settings) return;
-    try {
-      advancedSettingsText.value = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
-    } catch (_e) { /* leave as is */ }
-  },
-);
+// always sees the live state.
+['stream', 'sniffing', 'settings'].forEach((slice) => {
+  watch(
+    () => inbound.value && JSON.stringify(inbound.value[slice]?.toJson?.() || {}),
+    () => stampAdvancedTextFor(slice),
+  );
+});
 
 
-// Watch protocol changes to clear stream settings for protocols that don't support it
-watch(
-  () => inbound.value?.protocol,
-  () => {
-    if (!inbound.value) return;
-    if (!canEnableStream.value) {
-      advancedStreamText.value = '{}';
-    }
-  },
-);
+watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
 </script>
 </script>
 
 
 <template>
 <template>
@@ -2321,7 +2205,7 @@ watch(
 
 
 .advanced-panel__subtitle {
 .advanced-panel__subtitle {
   margin-top: 4px;
   margin-top: 4px;
-  color: rgba(0, 0, 0, 0.6);
+  opacity: 0.7;
   line-height: 1.5;
   line-height: 1.5;
 }
 }
 
 
@@ -2335,7 +2219,7 @@ watch(
 
 
 .advanced-editor-meta {
 .advanced-editor-meta {
   margin-bottom: 10px;
   margin-bottom: 10px;
-  color: rgba(0, 0, 0, 0.65);
+  opacity: 0.75;
   line-height: 1.5;
   line-height: 1.5;
 }
 }
 
 
@@ -2350,15 +2234,8 @@ watch(
   }
   }
 }
 }
 
 
-:global(.dark) .advanced-panel__subtitle,
-:global(.dark) .advanced-editor-meta,
-:global(.ultra) .advanced-panel__subtitle,
-:global(.ultra) .advanced-editor-meta {
-  color: rgba(255, 255, 255, 0.65);
-}
-
-:global(.dark) .advanced-panel,
-:global(.ultra) .advanced-panel {
+:global(body.dark) .advanced-panel,
+:global(html[data-theme='ultra-dark']) .advanced-panel {
   border-color: rgba(255, 255, 255, 0.12);
   border-color: rgba(255, 255, 255, 0.12);
   background: rgba(255, 255, 255, 0.03);
   background: rgba(255, 255, 255, 0.03);
 }
 }

+ 2 - 2
frontend/src/pages/nodes/NodeFormModal.vue

@@ -163,9 +163,9 @@ async function onSave() {
         </a-col>
         </a-col>
       </a-row>
       </a-row>
 
 
-      <a-form-item label="Allow private address">
+      <a-form-item :label="t('pages.nodes.allowPrivateAddress')">
         <a-switch v-model:checked="form.allowPrivateAddress" />
         <a-switch v-model:checked="form.allowPrivateAddress" />
-        <div class="hint">Enable only for nodes on a private network or VPN.</div>
+        <div class="hint">{{ t('pages.nodes.allowPrivateAddressHint') }}</div>
       </a-form-item>
       </a-form-item>
 
 
       <a-form-item :label="t('pages.nodes.apiToken')" required>
       <a-form-item :label="t('pages.nodes.apiToken')" required>

+ 13 - 2
frontend/src/pages/xray/BalancerFormModal.vue

@@ -61,6 +61,16 @@ const isValid = computed(
   () => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
   () => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
 );
 );
 
 
+const fallbackSupported = computed(
+  () => form.strategy === 'leastPing' || form.strategy === 'leastLoad',
+);
+
+watch(() => form.strategy, (next) => {
+  if (next !== 'leastPing' && next !== 'leastLoad') {
+    form.fallbackTag = '';
+  }
+});
+
 const tagValidateStatus = computed(() => {
 const tagValidateStatus = computed(() => {
   if (tagEmpty.value) return 'error';
   if (tagEmpty.value) return 'error';
   if (duplicateTag.value) return 'warning';
   if (duplicateTag.value) return 'warning';
@@ -111,8 +121,9 @@ const okText = computed(() =>
         </a-select>
         </a-select>
       </a-form-item>
       </a-form-item>
 
 
-      <a-form-item label="Fallback">
-        <a-select v-model:value="form.fallbackTag" allow-clear>
+      <a-form-item label="Fallback"
+        :help="fallbackSupported ? '' : 'Available only with Least ping / Least load'">
+        <a-select v-model:value="form.fallbackTag" allow-clear :disabled="!fallbackSupported">
           <a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
           <a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
             {{ tag || `(${t('none')})` }}
             {{ tag || `(${t('none')})` }}
           </a-select-option>
           </a-select-option>

+ 2 - 1
frontend/src/pages/xray/BalancersTab.vue

@@ -145,10 +145,11 @@ function syncObservatories() {
 }
 }
 
 
 function buildWireBalancer(form) {
 function buildWireBalancer(form) {
+  const supportsFallback = form.strategy === 'leastPing' || form.strategy === 'leastLoad';
   const out = {
   const out = {
     tag: form.tag,
     tag: form.tag,
     selector: [...form.selector],
     selector: [...form.selector],
-    fallbackTag: form.fallbackTag,
+    fallbackTag: supportsFallback ? form.fallbackTag : '',
   };
   };
   if (form.strategy && form.strategy !== 'random') {
   if (form.strategy && form.strategy !== 'random') {
     out.strategy = { type: form.strategy };
     out.strategy = { type: form.strategy };

+ 7 - 4
web/job/node_traffic_sync_job.go

@@ -87,10 +87,6 @@ func (j *NodeTrafficSyncJob) Run() {
 		return
 		return
 	}
 	}
 
 
-	online := j.inboundService.GetOnlineClients()
-	if online == nil {
-		online = []string{}
-	}
 	lastOnline, err := j.inboundService.GetClientsLastOnline()
 	lastOnline, err := j.inboundService.GetClientsLastOnline()
 	if err != nil {
 	if err != nil {
 		logger.Warning("node traffic sync: get last-online failed:", err)
 		logger.Warning("node traffic sync: get last-online failed:", err)
@@ -98,6 +94,13 @@ func (j *NodeTrafficSyncJob) Run() {
 	if lastOnline == nil {
 	if lastOnline == nil {
 		lastOnline = map[string]int64{}
 		lastOnline = map[string]int64{}
 	}
 	}
+
+	j.inboundService.RefreshOnlineClientsFromMap(lastOnline)
+
+	online := j.inboundService.GetOnlineClients()
+	if online == nil {
+		online = []string{}
+	}
 	websocket.BroadcastTraffic(map[string]any{
 	websocket.BroadcastTraffic(map[string]any{
 		"onlineClients": online,
 		"onlineClients": online,
 		"lastOnlineMap": lastOnline,
 		"lastOnlineMap": lastOnline,

+ 11 - 4
web/job/xray_traffic_job.go

@@ -77,10 +77,6 @@ func (j *XrayTrafficJob) Run() {
 	// a missing/null onlineClients field as "no update", so without this the
 	// a missing/null onlineClients field as "no update", so without this the
 	// "everyone went offline" transition was silently dropped — stale online
 	// "everyone went offline" transition was silently dropped — stale online
 	// users lingered in the list and the online filter kept showing them.
 	// users lingered in the list and the online filter kept showing them.
-	onlineClients := j.inboundService.GetOnlineClients()
-	if onlineClients == nil {
-		onlineClients = []string{}
-	}
 	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
 	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
 	if err != nil {
 	if err != nil {
 		logger.Warning("get clients last online failed:", err)
 		logger.Warning("get clients last online failed:", err)
@@ -88,6 +84,17 @@ func (j *XrayTrafficJob) Run() {
 	if lastOnlineMap == nil {
 	if lastOnlineMap == nil {
 		lastOnlineMap = make(map[string]int64)
 		lastOnlineMap = make(map[string]int64)
 	}
 	}
+
+	// Determine online clients from lastOnline timestamps with a 5-second
+	// grace period instead of just the current 5-second traffic poll. This
+	// prevents idle-but-connected clients from randomly disappearing from
+	// the UI between polling windows.
+	j.inboundService.RefreshOnlineClientsFromMap(lastOnlineMap)
+
+	onlineClients := j.inboundService.GetOnlineClients()
+	if onlineClients == nil {
+		onlineClients = []string{}
+	}
 	websocket.BroadcastTraffic(map[string]any{
 	websocket.BroadcastTraffic(map[string]any{
 		"traffics":       traffics,
 		"traffics":       traffics,
 		"clientTraffics": clientTraffics,
 		"clientTraffics": clientTraffics,

+ 38 - 22
web/service/inbound.go

@@ -521,6 +521,19 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 		return true, nil
 		return true, nil
 	}
 	}
 
 
+	// Remote nodes interpret DelInbound as a real row delete (it hits
+	// panel/api/inbounds/del/:id on the remote), so toggling the enable
+	// switch on a remote inbound used to wipe the row entirely (#4402).
+	// PATCH the remote row via UpdateInbound instead — preserves the
+	// settings/client history and just flips the enable flag.
+	if inbound.NodeID != nil {
+		if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
+			logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
+			return false, err
+		}
+		return false, nil
+	}
+
 	if err := rt.DelInbound(context.Background(), inbound); err != nil &&
 	if err := rt.DelInbound(context.Background(), inbound); err != nil &&
 		!strings.Contains(err.Error(), "not found") {
 		!strings.Contains(err.Error(), "not found") {
 		logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
 		logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
@@ -530,20 +543,13 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 		return needRestart, nil
 		return needRestart, nil
 	}
 	}
 
 
-	addTarget := inbound
-	if inbound.NodeID == nil {
-		runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
-		if err != nil {
-			logger.Debug("SetInboundEnable: build runtime config failed:", err)
-			return true, nil
-		}
-		addTarget = runtimeInbound
+	runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
+	if err != nil {
+		logger.Debug("SetInboundEnable: build runtime config failed:", err)
+		return true, nil
 	}
 	}
-	if err := rt.AddInbound(context.Background(), addTarget); err != nil {
+	if err := rt.AddInbound(context.Background(), runtimeInbound); err != nil {
 		logger.Debug("SetInboundEnable: AddInbound on", rt.Name(), "failed:", err)
 		logger.Debug("SetInboundEnable: AddInbound on", rt.Name(), "failed:", err)
-		if inbound.NodeID != nil {
-			return false, err
-		}
 		needRestart = true
 		needRestart = true
 	}
 	}
 	return needRestart, nil
 	return needRestart, nil
@@ -1539,6 +1545,13 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
 
 
 const resetGracePeriodMs int64 = 30000
 const resetGracePeriodMs int64 = 30000
 
 
+// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
+// Xray's stats counters often report a zero delta for an active session across
+// a single poll, so a 5s grace would still drop the client on the next tick.
+// ~4 polls of slack keeps idle-but-connected clients visible without lingering
+// long after a real disconnect.
+const onlineGracePeriodMs int64 = 20000
+
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
 	var structuralChange bool
 	var structuralChange bool
 	err := submitTrafficWrite(func() error {
 	err := submitTrafficWrite(func() error {
@@ -1880,15 +1893,9 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
 
 
 func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
 func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
 	if len(traffics) == 0 {
 	if len(traffics) == 0 {
-		// Empty onlineUsers
-		if p != nil {
-			p.SetOnlineClients(make([]string, 0))
-		}
 		return nil
 		return nil
 	}
 	}
 
 
-	onlineClients := make([]string, 0)
-
 	emails := make([]string, 0, len(traffics))
 	emails := make([]string, 0, len(traffics))
 	for _, traffic := range traffics {
 	for _, traffic := range traffics {
 		emails = append(emails, traffic.Email)
 		emails = append(emails, traffic.Email)
@@ -1931,14 +1938,10 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 		dbClientTraffics[dbTraffic_index].Down += t.Down
 		dbClientTraffics[dbTraffic_index].Down += t.Down
 		dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
 		dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
 		if t.Up+t.Down > 0 {
 		if t.Up+t.Down > 0 {
-			onlineClients = append(onlineClients, t.Email)
 			dbClientTraffics[dbTraffic_index].LastOnline = now
 			dbClientTraffics[dbTraffic_index].LastOnline = now
 		}
 		}
 	}
 	}
 
 
-	// Set onlineUsers
-	p.SetOnlineClients(onlineClients)
-
 	err = tx.Save(dbClientTraffics).Error
 	err = tx.Save(dbClientTraffics).Error
 	if err != nil {
 	if err != nil {
 		logger.Warning("AddClientTraffic update data ", err)
 		logger.Warning("AddClientTraffic update data ", err)
@@ -3764,6 +3767,19 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
 	return result, nil
 	return result, nil
 }
 }
 
 
+func (s *InboundService) RefreshOnlineClientsFromMap(lastOnlineMap map[string]int64) {
+	now := time.Now().UnixMilli()
+	newOnlineClients := make([]string, 0, len(lastOnlineMap))
+	for email, lastOnline := range lastOnlineMap {
+		if now-lastOnline < onlineGracePeriodMs {
+			newOnlineClients = append(newOnlineClients, email)
+		}
+	}
+	if p != nil {
+		p.SetOnlineClients(newOnlineClients)
+	}
+}
+
 func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
 func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
 	db := database.GetDB()
 	db := database.GetDB()
 
 

+ 79 - 6
web/service/outbound.go

@@ -190,7 +190,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 		wg.Add(1)
 		wg.Add(1)
 		go func(i int) {
 		go func(i int) {
 			defer wg.Done()
 			defer wg.Done()
-			results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
+			results[i] = probeEndpoint(endpoints[i], 5*time.Second)
 		}(i)
 		}(i)
 	}
 	}
 	wg.Wait()
 	wg.Wait()
@@ -207,7 +207,11 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 		}
 		}
 	}
 	}
 
 
-	out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
+	mode := "tcp"
+	if endpoints[0].Network == "udp" {
+		mode = "udp"
+	}
+	out := &TestOutboundResult{Mode: mode, Endpoints: results}
 	if bestDelay >= 0 {
 	if bestDelay >= 0 {
 		out.Success = true
 		out.Success = true
 		out.Delay = bestDelay
 		out.Delay = bestDelay
@@ -220,6 +224,22 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 	return out, nil
 	return out, nil
 }
 }
 
 
+// outboundEndpoint is a host:port plus the transport its proxy actually
+// listens on. WireGuard (and WARP, which is WireGuard) is UDP-only, so a
+// TCP dial to its peer endpoint always times out — the probe must match
+// the transport of the outbound being tested.
+type outboundEndpoint struct {
+	Address string
+	Network string
+}
+
+func probeEndpoint(ep outboundEndpoint, timeout time.Duration) TestEndpointResult {
+	if ep.Network == "udp" {
+		return probeUDPEndpoint(ep.Address, timeout)
+	}
+	return probeTCPEndpoint(ep.Address, timeout)
+}
+
 func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
 func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
 	r := TestEndpointResult{Address: endpoint}
 	r := TestEndpointResult{Address: endpoint}
 	start := time.Now()
 	start := time.Now()
@@ -234,18 +254,69 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
 	return r
 	return r
 }
 }
 
 
-func extractOutboundEndpoints(ob map[string]any) []string {
+// probeUDPEndpoint sends a single byte and waits briefly for a reply or
+// an ICMP-driven error. WireGuard won't answer an unauthenticated byte,
+// so a read timeout is the normal "endpoint reachable" outcome; a
+// concrete error (e.g. ECONNREFUSED, "host unreachable") fails the probe.
+func probeUDPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
+	r := TestEndpointResult{Address: endpoint}
+	start := time.Now()
+	conn, err := net.DialTimeout("udp", endpoint, timeout)
+	if err != nil {
+		r.Delay = time.Since(start).Milliseconds()
+		r.Error = err.Error()
+		return r
+	}
+	defer conn.Close()
+
+	if _, werr := conn.Write([]byte{0}); werr != nil {
+		r.Delay = time.Since(start).Milliseconds()
+		r.Error = werr.Error()
+		return r
+	}
+
+	_ = conn.SetReadDeadline(time.Now().Add(timeout))
+	buf := make([]byte, 64)
+	_, rerr := conn.Read(buf)
+	r.Delay = time.Since(start).Milliseconds()
+	if rerr != nil {
+		if nerr, ok := rerr.(net.Error); ok && nerr.Timeout() {
+			r.Success = true
+			return r
+		}
+		r.Error = rerr.Error()
+		return r
+	}
+	r.Success = true
+	return r
+}
+
+func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
 	protocol, _ := ob["protocol"].(string)
 	protocol, _ := ob["protocol"].(string)
 	settings, _ := ob["settings"].(map[string]any)
 	settings, _ := ob["settings"].(map[string]any)
 	if settings == nil {
 	if settings == nil {
 		return nil
 		return nil
 	}
 	}
-	var out []string
+
+	// Hysteria (and hysteria2 over trojan) is QUIC/UDP. Detect it via the
+	// outer protocol or via streamSettings.network so trojan-with-hysteria2
+	// transport gets probed over UDP too. kcp and quic are also UDP-based.
+	network := "tcp"
+	if protocol == "hysteria" || protocol == "wireguard" {
+		network = "udp"
+	}
+	if stream, ok := ob["streamSettings"].(map[string]any); ok {
+		if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
+			network = "udp"
+		}
+	}
+
+	var out []outboundEndpoint
 	addServer := func(addr any, port any) {
 	addServer := func(addr any, port any) {
 		host, _ := addr.(string)
 		host, _ := addr.(string)
 		p := numAsInt(port)
 		p := numAsInt(port)
 		if host != "" && p > 0 {
 		if host != "" && p > 0 {
-			out = append(out, fmt.Sprintf("%s:%d", host, p))
+			out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network})
 		}
 		}
 	}
 	}
 	switch protocol {
 	switch protocol {
@@ -259,6 +330,8 @@ func extractOutboundEndpoints(ob map[string]any) []string {
 		}
 		}
 	case "vless":
 	case "vless":
 		addServer(settings["address"], settings["port"])
 		addServer(settings["address"], settings["port"])
+	case "hysteria":
+		addServer(settings["address"], settings["port"])
 	case "trojan", "shadowsocks", "http", "socks":
 	case "trojan", "shadowsocks", "http", "socks":
 		if servers, ok := settings["servers"].([]any); ok {
 		if servers, ok := settings["servers"].([]any); ok {
 			for _, sv := range servers {
 			for _, sv := range servers {
@@ -272,7 +345,7 @@ func extractOutboundEndpoints(ob map[string]any) []string {
 			for _, p := range peers {
 			for _, p := range peers {
 				if pm, ok := p.(map[string]any); ok {
 				if pm, ok := p.(map[string]any); ok {
 					if ep, _ := pm["endpoint"].(string); ep != "" {
 					if ep, _ := pm["endpoint"].(string); ep != "" {
-						out = append(out, ep)
+						out = append(out, outboundEndpoint{Address: ep, Network: network})
 					}
 					}
 				}
 				}
 			}
 			}

+ 73 - 1
web/service/tgbot.go

@@ -1398,6 +1398,25 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 					return
 					return
 				}
 				}
 
 
+				t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
+				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
+			case "add_client_set_flow":
+				if dataArray[1] == "none" {
+					client_Flow = ""
+				} else {
+					client_Flow = dataArray[1]
+				}
+				messageId := callbackQuery.Message.GetMessageID()
+				inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
+				if err != nil {
+					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
+					return
+				}
+				message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
+				if err != nil {
+					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
+					return
+				}
 				t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
 				t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
 			case "add_client_ip_limit_in":
 			case "add_client_ip_limit_in":
@@ -1865,6 +1884,22 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 			),
 			),
 		)
 		)
 		t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
 		t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
+	case "add_client_ch_default_flow":
+		inlineKeyboard := tu.InlineKeyboard(
+			tu.InlineKeyboardRow(
+				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")),
+			),
+			tu.InlineKeyboardRow(
+				tu.InlineKeyboardButton("None").WithCallbackData(t.encodeQuery("add_client_set_flow none")),
+			),
+			tu.InlineKeyboardRow(
+				tu.InlineKeyboardButton("xtls-rprx-vision").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision")),
+			),
+			tu.InlineKeyboardRow(
+				tu.InlineKeyboardButton("xtls-rprx-vision-udp443").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision-udp443")),
+			),
+		)
+		t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
 	case "add_client_ch_default_ip_limit":
 	case "add_client_ch_default_ip_limit":
 		inlineKeyboard := tu.InlineKeyboard(
 		inlineKeyboard := tu.InlineKeyboard(
 			tu.InlineKeyboardRow(
 			tu.InlineKeyboardRow(
@@ -3345,6 +3380,25 @@ func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
 	}
 	}
 }
 }
 
 
+// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend
+// model: xtls-rprx-vision is only valid on VLESS-over-TCP with TLS or Reality.
+func inboundCanEnableTlsFlow(ib *model.Inbound) bool {
+	if ib == nil || ib.Protocol != model.VLESS {
+		return false
+	}
+	var stream struct {
+		Network  string `json:"network"`
+		Security string `json:"security"`
+	}
+	if err := json.Unmarshal([]byte(ib.StreamSettings), &stream); err != nil {
+		return false
+	}
+	if stream.Network != "tcp" {
+		return false
+	}
+	return stream.Security == "tls" || stream.Security == "reality"
+}
+
 // addClient handles the process of adding a new client to an inbound.
 // addClient handles the process of adding a new client to an inbound.
 func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
 func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
 	inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
 	inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
@@ -3357,13 +3411,31 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
 
 
 	var protocolRows [][]telego.InlineKeyboardButton
 	var protocolRows [][]telego.InlineKeyboardButton
 	switch protocol {
 	switch protocol {
-	case model.VMESS, model.VLESS:
+	case model.VMESS:
 		protocolRows = [][]telego.InlineKeyboardButton{
 		protocolRows = [][]telego.InlineKeyboardButton{
 			tu.InlineKeyboardRow(
 			tu.InlineKeyboardRow(
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
 			),
 			),
 		}
 		}
+	case model.VLESS:
+		protocolRows = [][]telego.InlineKeyboardButton{
+			tu.InlineKeyboardRow(
+				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
+				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
+			),
+		}
+		if inboundCanEnableTlsFlow(inbound) {
+			flowLabel := t.I18nBot("tgbot.buttons.change_flow")
+			if client_Flow != "" {
+				flowLabel = flowLabel + ": " + client_Flow
+			}
+			protocolRows = append(protocolRows, tu.InlineKeyboardRow(
+				tu.InlineKeyboardButton(flowLabel).WithCallbackData("add_client_ch_default_flow"),
+			))
+		} else if client_Flow != "" {
+			client_Flow = ""
+		}
 	case model.Trojan:
 	case model.Trojan:
 		protocolRows = [][]telego.InlineKeyboardButton{
 		protocolRows = [][]telego.InlineKeyboardButton{
 			tu.InlineKeyboardRow(
 			tu.InlineKeyboardRow(

+ 3 - 0
web/translation/ar-EG.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.",
       "apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.",
       "regenerate": "تجديد التوكن",
       "regenerate": "تجديد التوكن",
       "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
       "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
+      "allowPrivateAddress": "السماح بالعنوان الخاص",
+      "allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
       "enable": "مفعل",
       "enable": "مفعل",
       "status": "الحالة",
       "status": "الحالة",
       "cpu": "المعالج",
       "cpu": "المعالج",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 كلمة السر",
       "change_password": "⚙️🔑 كلمة السر",
       "change_email": "⚙️📧 البريد الإلكتروني",
       "change_email": "⚙️📧 البريد الإلكتروني",
       "change_comment": "⚙️💬 تعليق",
       "change_comment": "⚙️💬 تعليق",
+      "change_flow": "⚙️🚦 التدفق",
       "ResetAllTraffics": "إعادة ضبط جميع الترافيك",
       "ResetAllTraffics": "إعادة ضبط جميع الترافيك",
       "SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب"
       "SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب"
     },
     },

+ 3 - 0
web/translation/en-US.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "The remote panel exposes its API token under Settings → API Token.",
       "apiTokenHint": "The remote panel exposes its API token under Settings → API Token.",
       "regenerate": "Regenerate Token",
       "regenerate": "Regenerate Token",
       "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
       "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
+      "allowPrivateAddress": "Allow private address",
+      "allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
       "enable": "Enabled",
       "enable": "Enabled",
       "status": "Status",
       "status": "Status",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 Password",
       "change_password": "⚙️🔑 Password",
       "change_email": "⚙️📧 Email",
       "change_email": "⚙️📧 Email",
       "change_comment": "⚙️💬 Comment",
       "change_comment": "⚙️💬 Comment",
+      "change_flow": "⚙️🚦 Flow",
       "ResetAllTraffics": "Reset All Traffics",
       "ResetAllTraffics": "Reset All Traffics",
       "SortedTrafficUsageReport": "Sorted Traffic Usage Report"
       "SortedTrafficUsageReport": "Sorted Traffic Usage Report"
     },
     },

+ 3 - 0
web/translation/es-ES.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.",
       "apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.",
       "regenerate": "Regenerar token",
       "regenerate": "Regenerar token",
       "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
       "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
+      "allowPrivateAddress": "Permitir dirección privada",
+      "allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
       "enable": "Habilitado",
       "enable": "Habilitado",
       "status": "Estado",
       "status": "Estado",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 Contraseña",
       "change_password": "⚙️🔑 Contraseña",
       "change_email": "⚙️📧 Correo electrónico",
       "change_email": "⚙️📧 Correo electrónico",
       "change_comment": "⚙️💬 Comentario",
       "change_comment": "⚙️💬 Comentario",
+      "change_flow": "⚙️🚦 Flujo",
       "ResetAllTraffics": "Reiniciar todo el tráfico",
       "ResetAllTraffics": "Reiniciar todo el tráfico",
       "SortedTrafficUsageReport": "Informe de uso de tráfico ordenado"
       "SortedTrafficUsageReport": "Informe de uso de tráfico ordenado"
     },
     },

+ 3 - 0
web/translation/fa-IR.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.",
       "apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.",
       "regenerate": "تولید مجدد توکن",
       "regenerate": "تولید مجدد توکن",
       "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
       "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
+      "allowPrivateAddress": "اجازه آدرس خصوصی",
+      "allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
       "enable": "فعال",
       "enable": "فعال",
       "status": "وضعیت",
       "status": "وضعیت",
       "cpu": "پردازنده",
       "cpu": "پردازنده",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 گذرواژه",
       "change_password": "⚙️🔑 گذرواژه",
       "change_email": "⚙️📧 ایمیل",
       "change_email": "⚙️📧 ایمیل",
       "change_comment": "⚙️💬 نظر",
       "change_comment": "⚙️💬 نظر",
+      "change_flow": "⚙️🚦 جریان",
       "ResetAllTraffics": "بازنشانی همه ترافیک‌ها",
       "ResetAllTraffics": "بازنشانی همه ترافیک‌ها",
       "SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده"
       "SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده"
     },
     },

+ 3 - 0
web/translation/id-ID.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.",
       "apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.",
       "regenerate": "Buat Ulang Token",
       "regenerate": "Buat Ulang Token",
       "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
       "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
+      "allowPrivateAddress": "Izinkan alamat pribadi",
+      "allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
       "enable": "Aktif",
       "enable": "Aktif",
       "status": "Status",
       "status": "Status",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 Kata Sandi",
       "change_password": "⚙️🔑 Kata Sandi",
       "change_email": "⚙️📧 Email",
       "change_email": "⚙️📧 Email",
       "change_comment": "⚙️💬 Komentar",
       "change_comment": "⚙️💬 Komentar",
+      "change_flow": "⚙️🚦 Flow",
       "ResetAllTraffics": "Reset Semua Lalu Lintas",
       "ResetAllTraffics": "Reset Semua Lalu Lintas",
       "SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut"
       "SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut"
     },
     },

+ 3 - 0
web/translation/ja-JP.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。",
       "apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。",
       "regenerate": "トークンを再生成",
       "regenerate": "トークンを再生成",
       "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
       "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
+      "allowPrivateAddress": "プライベートアドレスを許可",
+      "allowPrivateAddressHint": "プライベートネットワークまたはVPN上のノードにのみ有効にします。",
       "enable": "有効",
       "enable": "有効",
       "status": "ステータス",
       "status": "ステータス",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 パスワード",
       "change_password": "⚙️🔑 パスワード",
       "change_email": "⚙️📧 メールアドレス",
       "change_email": "⚙️📧 メールアドレス",
       "change_comment": "⚙️💬 コメント",
       "change_comment": "⚙️💬 コメント",
+      "change_flow": "⚙️🚦 フロー",
       "ResetAllTraffics": "すべてのトラフィックをリセット",
       "ResetAllTraffics": "すべてのトラフィックをリセット",
       "SortedTrafficUsageReport": "ソートされたトラフィック使用レポート"
       "SortedTrafficUsageReport": "ソートされたトラフィック使用レポート"
     },
     },

+ 3 - 0
web/translation/pt-BR.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.",
       "apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.",
       "regenerate": "Regenerar token",
       "regenerate": "Regenerar token",
       "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
       "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
+      "allowPrivateAddress": "Permitir endereço privado",
+      "allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
       "enable": "Ativado",
       "enable": "Ativado",
       "status": "Status",
       "status": "Status",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 Senha",
       "change_password": "⚙️🔑 Senha",
       "change_email": "⚙️📧 E-mail",
       "change_email": "⚙️📧 E-mail",
       "change_comment": "⚙️💬 Comentário",
       "change_comment": "⚙️💬 Comentário",
+      "change_flow": "⚙️🚦 Fluxo",
       "ResetAllTraffics": "Redefinir Todo o Tráfego",
       "ResetAllTraffics": "Redefinir Todo o Tráfego",
       "SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado"
       "SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado"
     },
     },

+ 3 - 0
web/translation/ru-RU.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.",
       "apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.",
       "regenerate": "Сгенерировать токен заново",
       "regenerate": "Сгенерировать токен заново",
       "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
       "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
+      "allowPrivateAddress": "Разрешить частный адрес",
+      "allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
       "enable": "Включён",
       "enable": "Включён",
       "status": "Статус",
       "status": "Статус",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 Пароль",
       "change_password": "⚙️🔑 Пароль",
       "change_email": "⚙️📧 Email",
       "change_email": "⚙️📧 Email",
       "change_comment": "⚙️💬 Комментарий",
       "change_comment": "⚙️💬 Комментарий",
+      "change_flow": "⚙️🚦 Поток",
       "ResetAllTraffics": "Сбросить весь трафик",
       "ResetAllTraffics": "Сбросить весь трафик",
       "SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика"
       "SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика"
     },
     },

+ 3 - 0
web/translation/tr-TR.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.",
       "apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.",
       "regenerate": "Token'ı Yeniden Oluştur",
       "regenerate": "Token'ı Yeniden Oluştur",
       "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
       "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
+      "allowPrivateAddress": "Özel adrese izin ver",
+      "allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştir.",
       "enable": "Etkin",
       "enable": "Etkin",
       "status": "Durum",
       "status": "Durum",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 Şifre",
       "change_password": "⚙️🔑 Şifre",
       "change_email": "⚙️📧 E-posta",
       "change_email": "⚙️📧 E-posta",
       "change_comment": "⚙️💬 Yorum",
       "change_comment": "⚙️💬 Yorum",
+      "change_flow": "⚙️🚦 Akış",
       "ResetAllTraffics": "Tüm Trafikleri Sıfırla",
       "ResetAllTraffics": "Tüm Trafikleri Sıfırla",
       "SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu"
       "SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu"
     },
     },

+ 3 - 0
web/translation/uk-UA.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.",
       "apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.",
       "regenerate": "Перегенерувати токен",
       "regenerate": "Перегенерувати токен",
       "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
       "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
+      "allowPrivateAddress": "Дозволити приватну адресу",
+      "allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
       "enable": "Увімкнено",
       "enable": "Увімкнено",
       "status": "Статус",
       "status": "Статус",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 Пароль",
       "change_password": "⚙️🔑 Пароль",
       "change_email": "⚙️📧 Електронна пошта",
       "change_email": "⚙️📧 Електронна пошта",
       "change_comment": "⚙️💬 Коментар",
       "change_comment": "⚙️💬 Коментар",
+      "change_flow": "⚙️🚦 Потік",
       "ResetAllTraffics": "Скинути весь трафік",
       "ResetAllTraffics": "Скинути весь трафік",
       "SortedTrafficUsageReport": "Відсортований звіт про використання трафіку"
       "SortedTrafficUsageReport": "Відсортований звіт про використання трафіку"
     },
     },

+ 3 - 0
web/translation/vi-VN.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.",
       "apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.",
       "regenerate": "Tạo lại token",
       "regenerate": "Tạo lại token",
       "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
       "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
+      "allowPrivateAddress": "Cho phép địa chỉ riêng",
+      "allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
       "enable": "Kích hoạt",
       "enable": "Kích hoạt",
       "status": "Trạng thái",
       "status": "Trạng thái",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 Mật Khẩu",
       "change_password": "⚙️🔑 Mật Khẩu",
       "change_email": "⚙️📧 Email",
       "change_email": "⚙️📧 Email",
       "change_comment": "⚙️💬 Bình Luận",
       "change_comment": "⚙️💬 Bình Luận",
+      "change_flow": "⚙️🚦 Flow",
       "ResetAllTraffics": "Đặt lại tất cả lưu lượng",
       "ResetAllTraffics": "Đặt lại tất cả lưu lượng",
       "SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp"
       "SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp"
     },
     },

+ 3 - 0
web/translation/zh-CN.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。",
       "apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。",
       "regenerate": "重新生成令牌",
       "regenerate": "重新生成令牌",
       "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
       "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
+      "allowPrivateAddress": "允许私有地址",
+      "allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
       "enable": "已启用",
       "enable": "已启用",
       "status": "状态",
       "status": "状态",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 密码",
       "change_password": "⚙️🔑 密码",
       "change_email": "⚙️📧 邮箱",
       "change_email": "⚙️📧 邮箱",
       "change_comment": "⚙️💬 评论",
       "change_comment": "⚙️💬 评论",
+      "change_flow": "⚙️🚦 流控",
       "ResetAllTraffics": "重置所有流量",
       "ResetAllTraffics": "重置所有流量",
       "SortedTrafficUsageReport": "排序的流量使用报告"
       "SortedTrafficUsageReport": "排序的流量使用报告"
     },
     },

+ 3 - 0
web/translation/zh-TW.json

@@ -418,6 +418,8 @@
       "apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。",
       "apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。",
       "regenerate": "重新產生權杖",
       "regenerate": "重新產生權杖",
       "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
       "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
+      "allowPrivateAddress": "允許私有地址",
+      "allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
       "enable": "已啟用",
       "enable": "已啟用",
       "status": "狀態",
       "status": "狀態",
       "cpu": "CPU",
       "cpu": "CPU",
@@ -950,6 +952,7 @@
       "change_password": "⚙️🔑 密碼",
       "change_password": "⚙️🔑 密碼",
       "change_email": "⚙️📧 電子郵件",
       "change_email": "⚙️📧 電子郵件",
       "change_comment": "⚙️💬 評論",
       "change_comment": "⚙️💬 評論",
+      "change_flow": "⚙️🚦 流控",
       "ResetAllTraffics": "重設所有流量",
       "ResetAllTraffics": "重設所有流量",
       "SortedTrafficUsageReport": "排序過的流量使用報告"
       "SortedTrafficUsageReport": "排序過的流量使用報告"
     },
     },